Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
530097f
Replace custom amount type with money amount type
Flo0807 Jul 18, 2025
ffa9a6a
Add masked input
Flo0807 Jul 18, 2025
c52444d
Refactor currency field to use masked input
Flo0807 Jul 18, 2025
502803b
Remove money dependency from backpex
Flo0807 Jul 18, 2025
f9e4374
Install money in demo
Flo0807 Jul 18, 2025
c331680
Use hidden input for currency value
Flo0807 Jul 18, 2025
2c8a149
Update masked input
Flo0807 Aug 8, 2025
49e319b
Update js build
Flo0807 Aug 8, 2025
1e6e4ef
Rename masked_input to masked_number_input
Flo0807 Aug 8, 2025
3d03c2d
Remove debug message
Flo0807 Aug 8, 2025
90f039d
Remove binding
Flo0807 Aug 8, 2025
5b80804
Update default for `unit_position`
Flo0807 Aug 8, 2025
4a56ecf
Refactor number input to currency input
Flo0807 Aug 15, 2025
ba1bf96
Update lib/backpex/html/form.ex
Flo0807 Aug 22, 2025
1c81961
Update dependency igniter to v0.6.28 (#1428)
renovate[bot] Aug 22, 2025
48ac227
Update assets
Flo0807 Aug 22, 2025
caefd4b
Install backpex node dependencies
Flo0807 Aug 22, 2025
fe1b3b1
Merge branch 'develop' into feature/1280-improve-currency-field
Flo0807 Sep 19, 2025
5ca315e
Run `mix format`
Flo0807 Sep 19, 2025
b11c8d8
Merge branch 'develop' into feature/1280-improve-currency-field
Flo0807 Sep 19, 2025
1b5c979
Fix imask cannot be resolved
Flo0807 Sep 19, 2025
07cc077
Use USD as currency default
Flo0807 Sep 19, 2025
7e81909
Remove unused NODE_PATH env
Flo0807 Sep 19, 2025
79c2ad0
Install backpex deps
Flo0807 Sep 19, 2025
f5f2118
Add v0.16 upgrade guide
Flo0807 Sep 19, 2025
fbd5d60
Fix spelling mistake
Flo0807 Sep 26, 2025
e2b3487
Merge branch 'develop' into feature/1280-improve-currency-field
Flo0807 Oct 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ ENV PATH=/opt/scripts/:/opt/app/_build/prod/rel/demo/bin:$PATH
ARG MIX_ENV=prod
ENV MIX_ENV=$MIX_ENV

RUN yarn install --pure-lockfile

RUN mkdir demo
WORKDIR $APP_HOME/demo

Expand Down
49 changes: 49 additions & 0 deletions assets/js/hooks/_currency_input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import IMask from 'imask'

export default {
mounted () {
this.maskedInput = this.el.querySelector('[data-masked-input]')
this.hiddenInput = this.el.querySelector('[data-hidden-input]')

this.initializeMask()
},
initializeMask () {
const maskPattern = this.el.dataset.maskPattern

if (!maskPattern) {
console.error('You must provide a mask pattern in the data-masked-pattern attribute.')
return
}

this.maskOptions = {
mask: maskPattern,
lazy: false,
blocks: {
num: {
mask: Number,
thousandsSeparator: this.el.dataset.thousandsSeparator,
radix: this.el.dataset.radix
}
}
}

this.mask = IMask(this.maskedInput, this.maskOptions)
this.mask.unmaskedValue = this.rawValue(this.hiddenInput.value)
this.mask.on('accept', this.handleMaskChange.bind(this))
},
updated () {
this.handleMaskChange()
},
handleMaskChange () {
this.hiddenInput.value = this.rawValue(this.mask.value)
},
destroyed () {
this.mask.destroy()
},
rawValue (value) {
return value
.replace(this.el.dataset.unit || '', '')
.trim()
.replace(new RegExp(`\\${this.el.dataset.thousandsSeparator}`, 'g'), '')
}
}
1 change: 1 addition & 0 deletions assets/js/hooks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { default as BackpexSidebarSections } from './_sidebar_sections'
export { default as BackpexStickyActions } from './_sticky_actions'
export { default as BackpexThemeSelector } from './_theme_selector'
export { default as BackpexTooltip } from './_tooltip'
export { default as BackpexCurrencyInput } from './_currency_input'
7 changes: 7 additions & 0 deletions demo/config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ config :spark,
"Ash.Domain": [section_order: [:resources, :policies, :authorization, :domain, :execution]]
]

config :money,
default_currency: :EUR,
separator: ".",
delimiter: ",",
symbol_on_right: true,
symbol_space: true

config :demo,
namespace: Demo,
ecto_repos: [Demo.Repo],
Expand Down
4 changes: 1 addition & 3 deletions demo/lib/demo/invoice.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@ defmodule Demo.Invoice do
schema "invoices" do
field :company, :string

field :amount, Backpex.Ecto.Amount.Type,
currency: :EUR,
opts: [separator: ".", delimiter: ",", symbol_on_right: true, symbol_space: true]
field :amount, Money.Ecto.Amount.Type

timestamps()
end
Expand Down
4 changes: 1 addition & 3 deletions demo/lib/demo/product.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ defmodule Demo.Product do
field :manufacturer, :string
field :images, {:array, :string}

field :price, Backpex.Ecto.Amount.Type,
currency: :EUR,
opts: [separator: ".", delimiter: ",", symbol_on_right: true, symbol_space: true]
field :price, Money.Ecto.Amount.Type

has_many :suppliers, Supplier, on_replace: :delete, on_delete: :delete_all
has_many :short_links, ShortLink, on_replace: :delete, on_delete: :delete_all, foreign_key: :product_id
Expand Down
1 change: 1 addition & 0 deletions demo/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ defmodule Demo.MixProject do
{:jason, ">= 1.0.0"},
{:ash, "~> 3.0"},
{:ash_postgres, "~> 2.6.0"},
{:money, "~> 1.14"},

# assets
{:esbuild, "~> 0.9", runtime: Mix.env() == :dev},
Expand Down
61 changes: 0 additions & 61 deletions lib/backpex/ecto/amount_type.ex

This file was deleted.

80 changes: 41 additions & 39 deletions lib/backpex/fields/currency.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ defmodule Backpex.Fields.Currency do
throttle: [
doc: "Timeout value (in milliseconds) or function that receives the assigns.",
type: {:or, [:pos_integer, {:fun, 1}]}
],
unit: [
doc: "Unit to display with the currency value, e.g. '€'.",
type: :string,
default: "€"
],
unit_position: [
doc: "Position of the unit relative to the value, either `:before` or `:after`.",
type: {:in, [:before, :after]},
default: :after
],
radix: [
doc:
"Character used as the decimal separator, e.g. ',' or '.'. Make sure this value matches the one you've configured in your Money library.",
type: :string,
default: ","
],
thousands_separator: [
doc:
"Character used as the thousands separator, e.g. '.' or ','. Make sure this value matches the one you've configured in your Money library.",
type: :string,
default: "."
]
]

Expand All @@ -21,16 +43,14 @@ defmodule Backpex.Fields.Currency do

## Schema

`Backpex.Ecto.Amount.Type` provides a type for Ecto to store a amount. The underlying data type should be an integer.
For a full list of configuration options see: https://hexdocs.pm/money/Money.html#module-configuration
Backpex expects you to use a Money library or a similar approach for handling currency values and dumping / casting them correctly in your database schema.

schema "article" do
field :price, Backpex.Ecto.Amount.Type
...
end
Ensure that your schema field is set up to handle the currency type appropriately.

For example, if you are using the [Money](https://hex.pm/packages/money) library, your schema might look like this:

schema "article" do
field :price, Backpex.Ecto.Amount.Type, currency: :EUR, opts: [separator: ".", delimiter: ","]
field :price, Money.Ecto.Amount.Type
...
end

Expand All @@ -41,46 +61,46 @@ defmodule Backpex.Fields.Currency do
[
price: %{
module: Backpex.Fields.Currency,
label: "Price"
label: "Price",
unit: "€",
radix: ",",
thousands_separator: "."
}
]
end
"""
use Backpex.Field, config_schema: @config_schema

import Ecto.Query
alias Backpex.Ecto.Amount.Type

@impl Backpex.Field
def render_value(assigns) do
schema = assigns.live_resource.adapter_config(:schema)
assigns = assign(assigns, :casted_value, maybe_cast_value(assigns.name, schema, assigns.value))

~H"""
<p class={@live_action in [:index, :resource_action] && "truncate"}>
{@casted_value}
{@value}
</p>
"""
end

@impl Backpex.Field
def render_form(assigns) do
assigns = assign(assigns, :casted_value, maybe_cast_form(PhoenixForm.input_value(assigns.form, assigns.name)))

~H"""
<div>
<Layout.field_container>
<:label align={Backpex.Field.align_label(@field_options, assigns)}>
<Layout.input_label text={@field_options[:label]} />
</:label>
<BackpexForm.input
type="number"
<BackpexForm.currency_input
type="text"
field={@form[@name]}
value={@casted_value}
translate_error_fun={Backpex.Field.translate_error_fun(@field_options, assigns)}
help_text={Backpex.Field.help_text(@field_options, assigns)}
phx-debounce={Backpex.Field.debounce(@field_options, assigns)}
phx-throttle={Backpex.Field.throttle(@field_options, assigns)}
step=".01"
min="0"
radix={@field_options[:radix]}
thousands_separator={@field_options[:thousands_separator]}
unit={@field_options[:unit]}
unit_position={@field_options[:unit_position]}
/>
</Layout.field_container>
</div>
Expand All @@ -91,25 +111,7 @@ defmodule Backpex.Fields.Currency do
def search_condition(schema_name, field_name, search_string) do
Copy link
Collaborator Author

@Flo0807 Flo0807 Aug 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still, the UX when searching for currency is not very good, as you have to search for integer values. We could add another field_option that is used in this function to convert the search string into an integer. (However, we would need to modify the function to receive the field options...)

WDYT? @krns @pehbehbeh

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the same apply for Filters like a Range filter?

To have some kind of options to transform the search term sounds good.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have the same problem when using a range filter. The search_condition is not used there, so I guess we should leave it like it is and find a solution that covers both filters and field search.

dynamic(
[{^schema_name, schema_name}],
ilike(fragment("CAST(? AS TEXT)", schema_name |> field(^field_name)), ^search_string)
ilike(fragment("CAST(? AS TEXT)", field(schema_name, ^field_name)), ^search_string)
)
end

defp maybe_cast_value(field_name, schema, value) do
type = schema.__schema__(:type, field_name) || schema.__schema__(:virtual_type, field_name)

case type do
{:parameterized, Backpex.Ecto.Amount.Type, opts} ->
{:ok, money} = Type.cast(value, opts)

Money.to_string(money, Keyword.get(opts, :opts, []))

_type ->
value
end
end

defp maybe_cast_form(val) when is_binary(val), do: val
defp maybe_cast_form(nil), do: Decimal.new("0.00")
defp maybe_cast_form(%Money{} = value), do: Money.to_decimal(value)
end
84 changes: 84 additions & 0 deletions lib/backpex/html/form.ex
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,90 @@ defmodule Backpex.HTML.Form do
"""
end

@doc """
Renders a masked input for currencies.

A `Phoenix.HTML.FormField` may be passed as argument, which is used to retrieve the input name, id, and values.
Otherwise all attributes may be passed explicitly.

## Examples

<.currency_input field={@form[:amount]} unit="€" unit_position={:after} />
<.currency_input id="amount-input" name="amount" value="20" unit="$" unit_position={:before} readonly />
"""
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :help_text, :string, default: nil
attr :value, :any

attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]"

attr :errors, :list, default: []

attr :class, :any, default: nil, doc: "additional classes for the container element"

attr :input_class, :any,
default: nil,
doc: "the input class to use over defaults, note that this is applied to a wrapper span element
to allow for proper styling of the masked input"

attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"

attr :translate_error_fun, :any, default: &Function.identity/1, doc: "a custom function to map form errors"
attr :hide_errors, :boolean, default: false, doc: "if errors should be hidden"

attr :unit, :string, required: true
attr :unit_position, :atom, required: true, values: ~w(before after)a
attr :radix, :string, default: "."
attr :thousands_separator, :string, default: ","

attr :rest, :global, include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)

def currency_input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []

assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, translate_form_errors(errors, assigns.translate_error_fun))
|> assign_new(:name, fn -> field.name end)
|> assign_new(:value, fn -> field.value end)
|> currency_input()
end

def currency_input(assigns) do
assigns = assign(assigns, :mask_pattern, build_mask_pattern(assigns.unit_position, assigns.unit))

~H"""
<div class={["fieldset py-0", @class]}>
<span :if={@label} class="label mb-1">{@label}</span>
<div
id={@id}
phx-hook="BackpexCurrencyInput"
data-radix={@radix}
data-thousands-separator={@thousands_separator}
data-unit={@unit}
data-mask-pattern={@mask_pattern}
>
<%!-- As the input ignores updates, we need to wrap it in a span to apply the styles correctly --%>
<span class={[
@input_class || "[&_>_input]:input [&_>_input]:w-full",
@errors != [] && (@error_class || "[&_>_input]:input-error [&_>_input]:bg-error/10")
]}>
<input id={"#{@id}_masked"} name={@name} data-masked-input phx-update="ignore" {@rest} />
<input type="hidden" value={@value} name={@name} data-hidden-input />
</span>
</div>
<.error :for={msg <- @errors} :if={not @hide_errors}>{msg}</.error>
<.help_text :if={@help_text}>{@help_text}</.help_text>
</div>
"""
end

defp build_mask_pattern(:before = _unit_position, unit), do: "#{unit} num"
defp build_mask_pattern(:after = _unit_position, unit), do: "num #{unit}"

@doc """
Generates a generic error message.
"""
Expand Down
Loading
Loading