mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
Adapt holdings to number inputs (#1258)
* Adapt holdings to number inputs * Reviews * FIX a small provider hardcoded name * PR l10n request --------- Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -1,9 +1,22 @@
|
||||
// Parses a float from a string that may use either commas or dots as decimal separators.
|
||||
// Handles formats like "1,234.56" (English) and "1.234,56" (French/European).
|
||||
export default function parseLocaleFloat(value) {
|
||||
//
|
||||
// When a `separator` hint is provided (e.g., from currency metadata), parsing is
|
||||
// deterministic. Without a hint, a heuristic detects the format from the string.
|
||||
export default function parseLocaleFloat(value, { separator } = {}) {
|
||||
if (typeof value !== "string") return Number.parseFloat(value) || 0
|
||||
|
||||
const cleaned = value.replace(/\s/g, "")
|
||||
|
||||
// Deterministic parsing when the currency's decimal separator is known
|
||||
if (separator === ",") {
|
||||
return Number.parseFloat(cleaned.replace(/\./g, "").replace(",", ".")) || 0
|
||||
}
|
||||
if (separator === ".") {
|
||||
return Number.parseFloat(cleaned.replace(/,/g, "")) || 0
|
||||
}
|
||||
|
||||
// Heuristic: detect separator from the string when no hint is available
|
||||
const lastComma = cleaned.lastIndexOf(",")
|
||||
const lastDot = cleaned.lastIndexOf(".")
|
||||
|
||||
|
||||
@@ -61,12 +61,12 @@
|
||||
<label class="form-field__label"><%= t(".total_cost_basis_label") %></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
||||
<input type="text" inputmode="decimal"
|
||||
<input type="number" step="any"
|
||||
name="holding[cost_basis]"
|
||||
class="form-field__input grow"
|
||||
placeholder="0.00"
|
||||
autocomplete="off"
|
||||
value="<%= number_with_precision(current_total, precision: 2) if current_total %>"
|
||||
value="<%= sprintf("%.2f", current_total) if current_total %>"
|
||||
data-action="input->cost-basis-form#updatePerShare"
|
||||
data-cost-basis-form-target="total">
|
||||
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
||||
@@ -82,11 +82,11 @@
|
||||
<label class="text-xs text-secondary block mb-1"><%= t(".or_per_share_label") %></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
||||
<input type="text" inputmode="decimal"
|
||||
<input type="number" step="any"
|
||||
class="form-field__input grow"
|
||||
placeholder="0.00"
|
||||
autocomplete="off"
|
||||
value="<%= number_with_precision(current_per_share, precision: 2) if current_per_share %>"
|
||||
value="<%= sprintf("%.2f", current_per_share) if current_per_share %>"
|
||||
data-action="input->cost-basis-form#updateTotal"
|
||||
data-cost-basis-form-target="perShare">
|
||||
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
||||
|
||||
@@ -133,12 +133,12 @@
|
||||
<label class="form-field__label"><%= t("holdings.cost_basis_cell.total_cost_basis_label") %></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
||||
<input type="text" inputmode="decimal"
|
||||
<input type="number" step="any"
|
||||
name="holding[cost_basis]"
|
||||
class="form-field__input grow"
|
||||
placeholder="0.00"
|
||||
autocomplete="off"
|
||||
value="<%= number_with_precision(current_total, precision: 2) if current_total %>"
|
||||
value="<%= sprintf("%.2f", current_total) if current_total %>"
|
||||
data-action="input->drawer-cost-basis#updatePerShare"
|
||||
data-drawer-cost-basis-target="total">
|
||||
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
||||
@@ -153,11 +153,11 @@
|
||||
<label class="text-xs text-secondary block mb-1"><%= t("holdings.cost_basis_cell.or_per_share_label") %></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
||||
<input type="text" inputmode="decimal"
|
||||
<input type="number" step="any"
|
||||
class="form-field__input grow"
|
||||
placeholder="0.00"
|
||||
autocomplete="off"
|
||||
value="<%= number_with_precision(current_per_share, precision: 2) if current_per_share %>"
|
||||
value="<%= sprintf("%.2f", current_per_share) if current_per_share %>"
|
||||
data-action="input->drawer-cost-basis#updateTotal"
|
||||
data-drawer-cost-basis-target="perShare">
|
||||
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</h3>
|
||||
|
||||
<% if entry.linked? %>
|
||||
<span title="Linked with Plaid">
|
||||
<span title="<%= t("transactions.transaction.linked_with_provider", provider: entry.account.provider_name&.titleize) %>">
|
||||
<%= icon("refresh-ccw", size: "sm") %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<%= icon "arrow-left-right", size: "sm", class: "text-secondary" %>
|
||||
<% end %>
|
||||
<% if entry.linked? %>
|
||||
<span title="<%= t("transactions.transaction.linked_with_plaid") %>" class="text-secondary">
|
||||
<span title="<%= t("transactions.transaction.linked_with_provider", provider: entry.account.provider_name&.titleize) %>" class="text-secondary">
|
||||
<%= icon("refresh-ccw", size: "sm") %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
@@ -122,6 +122,7 @@ ca:
|
||||
transaction:
|
||||
pending: Pendent
|
||||
pending_tooltip: Transacció pendent — pot canviar quan es publiqui
|
||||
linked_with_provider: Vinculat amb %{provider}
|
||||
possible_duplicate: Duplicat?
|
||||
potential_duplicate_tooltip: Això pot ser un duplicat d'una altra transacció
|
||||
review_recommended: Revisa
|
||||
|
||||
@@ -75,7 +75,7 @@ de:
|
||||
transaction:
|
||||
pending: Ausstehend
|
||||
pending_tooltip: Ausstehende Transaktion — kann sich bei Buchung ändern
|
||||
linked_with_plaid: Mit Plaid verknüpft
|
||||
linked_with_provider: Mit %{provider} verknüpft
|
||||
activity_type_tooltip: Art der Anlageaktivität
|
||||
possible_duplicate: Duplikat?
|
||||
potential_duplicate_tooltip: Dies könnte ein Duplikat einer anderen Transaktion sein
|
||||
|
||||
@@ -86,7 +86,7 @@ en:
|
||||
transaction:
|
||||
pending: Pending
|
||||
pending_tooltip: Pending transaction — may change when posted
|
||||
linked_with_plaid: Linked with Plaid
|
||||
linked_with_provider: Linked with %{provider}
|
||||
activity_type_tooltip: Investment activity type
|
||||
possible_duplicate: Duplicate?
|
||||
potential_duplicate_tooltip: This may be a duplicate of another transaction
|
||||
|
||||
@@ -76,7 +76,7 @@ es:
|
||||
transaction:
|
||||
pending: Pendiente
|
||||
pending_tooltip: Transacción pendiente — puede cambiar al confirmarse
|
||||
linked_with_plaid: Vinculado con Plaid
|
||||
linked_with_provider: Vinculado con %{provider}
|
||||
activity_type_tooltip: Tipo de actividad de inversión
|
||||
possible_duplicate: ¿Duplicada?
|
||||
potential_duplicate_tooltip: Esto puede ser un duplicado de otra transacción
|
||||
|
||||
@@ -45,6 +45,8 @@ fr:
|
||||
edit_merchants: Modifier les marchands
|
||||
edit_tags: Modifier les étiquettes
|
||||
import: Importer
|
||||
transaction:
|
||||
linked_with_provider: Lié avec %{provider}
|
||||
index:
|
||||
transaction: transaction
|
||||
transactions: transactions
|
||||
|
||||
@@ -46,6 +46,8 @@ nb:
|
||||
edit_merchants: Rediger selgere
|
||||
edit_tags: Rediger tagger
|
||||
import: Importer
|
||||
transaction:
|
||||
linked_with_provider: Koblet med %{provider}
|
||||
index:
|
||||
transaction: transaksjon
|
||||
transactions: transaksjoner
|
||||
|
||||
@@ -74,6 +74,7 @@ nl:
|
||||
transaction:
|
||||
pending: Wachtend
|
||||
pending_tooltip: Wachtende transactie — kan wijzigen bij posting
|
||||
linked_with_provider: Gekoppeld met %{provider}
|
||||
activity_type_tooltip: Beleggingsactiviteitstype
|
||||
possible_duplicate: Duplicaat?
|
||||
potential_duplicate_tooltip: Dit kan een duplicaat zijn van een andere transactie
|
||||
|
||||
@@ -49,6 +49,8 @@ pt-BR:
|
||||
edit_merchants: Editar comerciantes
|
||||
edit_tags: Editar tags
|
||||
import: Importar
|
||||
transaction:
|
||||
linked_with_provider: Vinculado com %{provider}
|
||||
index:
|
||||
transaction: transação
|
||||
transactions: transações
|
||||
|
||||
@@ -45,6 +45,8 @@ ro:
|
||||
edit_merchants: Editează comercianți
|
||||
edit_tags: Editează etichete
|
||||
import: Importă
|
||||
transaction:
|
||||
linked_with_provider: Conectat cu %{provider}
|
||||
index:
|
||||
transaction: tranzacție
|
||||
transactions: tranzacții
|
||||
|
||||
@@ -45,6 +45,8 @@ tr:
|
||||
edit_merchants: Satıcıları düzenle
|
||||
edit_tags: Etiketleri düzenle
|
||||
import: İçe aktar
|
||||
transaction:
|
||||
linked_with_provider: "%{provider} ile bağlantılı"
|
||||
index:
|
||||
transaction: işlem
|
||||
transactions: işlemler
|
||||
|
||||
@@ -25,6 +25,8 @@ zh-CN:
|
||||
edit_merchants: 编辑商户
|
||||
edit_tags: 编辑标签
|
||||
import: 导入
|
||||
transaction:
|
||||
linked_with_provider: 已与 %{provider} 关联
|
||||
index:
|
||||
import: 导入
|
||||
transaction: 交易
|
||||
|
||||
@@ -48,6 +48,8 @@ zh-TW:
|
||||
edit_merchants: 編輯商家
|
||||
edit_tags: 編輯標籤
|
||||
import: 匯入
|
||||
transaction:
|
||||
linked_with_provider: 已與 %{provider} 連結
|
||||
index:
|
||||
transaction: 筆交易
|
||||
transactions: 筆交易
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { describe, it } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
// Inline the function to avoid needing a bundler for ESM imports
|
||||
function parseLocaleFloat(value) {
|
||||
// Inline the function to avoid needing a bundler for ESM imports.
|
||||
// Must be kept in sync with app/javascript/utils/parse_locale_float.js
|
||||
function parseLocaleFloat(value, { separator } = {}) {
|
||||
if (typeof value !== "string") return Number.parseFloat(value) || 0
|
||||
|
||||
const cleaned = value.replace(/\s/g, "")
|
||||
|
||||
if (separator === ",") {
|
||||
return Number.parseFloat(cleaned.replace(/\./g, "").replace(",", ".")) || 0
|
||||
}
|
||||
if (separator === ".") {
|
||||
return Number.parseFloat(cleaned.replace(/,/g, "")) || 0
|
||||
}
|
||||
|
||||
const lastComma = cleaned.lastIndexOf(",")
|
||||
const lastDot = cleaned.lastIndexOf(".")
|
||||
|
||||
@@ -74,6 +83,10 @@ describe("parseLocaleFloat", () => {
|
||||
it("treats 1,000 as one thousand", () => {
|
||||
assert.equal(parseLocaleFloat("1,000"), 1000)
|
||||
})
|
||||
|
||||
it("treats 1,000,000 as one million", () => {
|
||||
assert.equal(parseLocaleFloat("1,000,000"), 1000000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("integers", () => {
|
||||
@@ -96,6 +109,79 @@ describe("parseLocaleFloat", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("negative numbers", () => {
|
||||
it("parses negative dot-decimal", () => {
|
||||
assert.equal(parseLocaleFloat("-1,234.56"), -1234.56)
|
||||
})
|
||||
|
||||
it("parses negative comma-decimal", () => {
|
||||
assert.equal(parseLocaleFloat("-1.234,56"), -1234.56)
|
||||
})
|
||||
|
||||
it("parses simple negative", () => {
|
||||
assert.equal(parseLocaleFloat("-256.54"), -256.54)
|
||||
})
|
||||
|
||||
it("parses negative European simple", () => {
|
||||
assert.equal(parseLocaleFloat("-256,54"), -256.54)
|
||||
})
|
||||
})
|
||||
|
||||
describe("with separator hint", () => {
|
||||
describe("comma separator (European currencies like EUR)", () => {
|
||||
const opts = { separator: "," }
|
||||
|
||||
it("disambiguates 1,234 as 1.234 (European decimal)", () => {
|
||||
assert.equal(parseLocaleFloat("1,234", opts), 1.234)
|
||||
})
|
||||
|
||||
it("parses 1.234,56 correctly", () => {
|
||||
assert.equal(parseLocaleFloat("1.234,56", opts), 1234.56)
|
||||
})
|
||||
|
||||
it("parses simple comma decimal", () => {
|
||||
assert.equal(parseLocaleFloat("256,54", opts), 256.54)
|
||||
})
|
||||
|
||||
it("parses integer without separators", () => {
|
||||
assert.equal(parseLocaleFloat("1234", opts), 1234)
|
||||
})
|
||||
|
||||
it("parses negative value", () => {
|
||||
assert.equal(parseLocaleFloat("-1.234,56", opts), -1234.56)
|
||||
})
|
||||
})
|
||||
|
||||
describe("dot separator (English currencies like USD)", () => {
|
||||
const opts = { separator: "." }
|
||||
|
||||
it("disambiguates 1,234 as 1234 (English thousands)", () => {
|
||||
assert.equal(parseLocaleFloat("1,234", opts), 1234)
|
||||
})
|
||||
|
||||
it("parses 1,234.56 correctly", () => {
|
||||
assert.equal(parseLocaleFloat("1,234.56", opts), 1234.56)
|
||||
})
|
||||
|
||||
it("parses simple dot decimal", () => {
|
||||
assert.equal(parseLocaleFloat("256.54", opts), 256.54)
|
||||
})
|
||||
|
||||
it("parses integer without separators", () => {
|
||||
assert.equal(parseLocaleFloat("1234", opts), 1234)
|
||||
})
|
||||
|
||||
it("parses negative value", () => {
|
||||
assert.equal(parseLocaleFloat("-1,234.56", opts), -1234.56)
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to heuristic when no hint given", () => {
|
||||
assert.equal(parseLocaleFloat("1,234"), 1234)
|
||||
assert.equal(parseLocaleFloat("256,54"), 256.54)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("returns 0 for empty string", () => {
|
||||
assert.equal(parseLocaleFloat(""), 0)
|
||||
|
||||
Reference in New Issue
Block a user