From 7ff982b31cf2936e0eb31f8308ccea48bae7a091 Mon Sep 17 00:00:00 2001 From: nielsbosma Date: Wed, 1 Apr 2026 23:58:51 +0200 Subject: [PATCH] [01415] Fix DataTable footer to format currency/number aggregates The Footer() method was building footer strings via string interpolation without applying the column's FormatStyle, Currency, or Precision settings. This caused currency columns to display concatenated raw values (e.g. "$10,543.56$150,587.95") instead of a properly formatted sum. Added FormatFooterValue helper that applies the column's number formatting (Currency, Percent, Decimal) to aggregate results, and GetCultureForCurrency to resolve ISO currency codes to the correct CultureInfo for formatting. --- .../DataTables/DataTableFooterFormatTests.cs | 103 ++++++++++++++++++ src/Ivy/Views/DataTables/DataTableBuilder.cs | 53 ++++++++- 2 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 src/Ivy.Tests/Views/DataTables/DataTableFooterFormatTests.cs diff --git a/src/Ivy.Tests/Views/DataTables/DataTableFooterFormatTests.cs b/src/Ivy.Tests/Views/DataTables/DataTableFooterFormatTests.cs new file mode 100644 index 0000000000..1aaa65b2a9 --- /dev/null +++ b/src/Ivy.Tests/Views/DataTables/DataTableFooterFormatTests.cs @@ -0,0 +1,103 @@ +using Ivy; + +namespace Ivy.Tests.Views.DataTables; + +public class DataTableFooterFormatTests +{ + private record SalesRow(string Product, decimal Amount, double Rate, int Count); + + private static IQueryable SampleData() => + new[] + { + new SalesRow("A", 10_543.56m, 0.152, 100), + new SalesRow("B", 150_587.95m, 0.348, 200), + }.AsQueryable(); + + private static List GetFooter(DataTableBuilder builder, string columnName) + { + // Access the private _columns dictionary via reflection + var flags = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; + var columnsField = builder.GetType().GetField("_columns", flags)!; + var columnsObj = columnsField.GetValue(builder)!; + + // Get the InternalColumn via the dictionary's Item property (indexer) + var dictType = columnsObj.GetType(); + var itemProp = dictType.GetProperty("Item")!; + var internalColumn = itemProp.GetValue(columnsObj, [columnName])!; + + // Get the Column property from InternalColumn + var columnProp = internalColumn.GetType().GetProperty("Column")!; + var column = (DataTableColumn)columnProp.GetValue(internalColumn)!; + return column.Footer ?? []; + } + + [Fact] + public void Footer_WithCurrencyFormat_FormatsAggregateWithCurrencySymbol() + { + var builder = SampleData().ToDataTable() + .Format(x => x.Amount, NumberFormatStyle.Currency, precision: 2, currency: "USD") + .Footer(x => x.Amount, "Total", values => values.Sum()); + + var footer = GetFooter(builder, "Amount"); + Assert.Single(footer); + Assert.Contains("$", footer[0]); + Assert.Contains("161,131.51", footer[0]); + Assert.StartsWith("Total: ", footer[0]); + } + + [Fact] + public void Footer_WithPercentFormat_FormatsAsPercentage() + { + var builder = SampleData().ToDataTable() + .Format(x => x.Rate, NumberFormatStyle.Percent, precision: 1) + .Footer(x => x.Rate, "Avg", values => values.Average()); + + var footer = GetFooter(builder, "Rate"); + Assert.Single(footer); + // Average of 0.152 and 0.348 = 0.25 → 25.0% + Assert.Contains("25.0", footer[0]); + Assert.Contains("%", footer[0]); + } + + [Fact] + public void Footer_WithDecimalFormat_FormatsWithGroupingSeparators() + { + var builder = SampleData().ToDataTable() + .Format(x => x.Count, NumberFormatStyle.Decimal, precision: 0) + .Footer(x => x.Count, "Total", values => values.Sum()); + + var footer = GetFooter(builder, "Count"); + Assert.Single(footer); + Assert.Equal("Total: 300", footer[0]); + } + + [Fact] + public void Footer_WithoutFormatStyle_PassesThroughToString() + { + var builder = SampleData().ToDataTable() + .Footer(x => x.Count, "Total", values => values.Sum()); + + var footer = GetFooter(builder, "Count"); + Assert.Single(footer); + Assert.Equal("Total: 300", footer[0]); + } + + [Fact] + public void Footer_MultipleAggregates_WithCurrencyFormat_EachGetsFormatted() + { + var builder = SampleData().ToDataTable() + .Format(x => x.Amount, NumberFormatStyle.Currency, precision: 2, currency: "USD") + .Footer(x => x.Amount, new (string, Func, object>)[] + { + ("Sum", values => values.Sum()), + ("Avg", values => values.Average()), + }); + + var footer = GetFooter(builder, "Amount"); + Assert.Equal(2, footer.Count); + Assert.Contains("$", footer[0]); + Assert.Contains("161,131.51", footer[0]); + Assert.Contains("$", footer[1]); + Assert.Contains("80,565.76", footer[1]); + } +} diff --git a/src/Ivy/Views/DataTables/DataTableBuilder.cs b/src/Ivy/Views/DataTables/DataTableBuilder.cs index eaa4979ec3..d21c0cc6aa 100644 --- a/src/Ivy/Views/DataTables/DataTableBuilder.cs +++ b/src/Ivy/Views/DataTables/DataTableBuilder.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Globalization; using System.Linq.Expressions; using System.Reflection; using Ivy.Core.Helpers; @@ -182,7 +183,7 @@ public DataTableBuilder Footer( var selector = field.Compile(); var values = GetOrCreateFooterValueList(field, selector); var result = aggregateFunc(values); - var footerText = $"{label}: {result}"; + var footerText = $"{label}: {FormatFooterValue(column.Column, result)}"; column.Column.Footer ??= []; column.Column.Footer.Add(footerText); return this; @@ -196,13 +197,61 @@ public DataTableBuilder Footer( var selector = field.Compile(); var values = GetOrCreateFooterValueList(field, selector); var footerValues = aggregates - .Select(agg => $"{agg.Label}: {agg.AggregateFunc(values)}") + .Select(agg => $"{agg.Label}: {FormatFooterValue(column.Column, agg.AggregateFunc(values))}") .ToList(); column.Column.Footer ??= []; column.Column.Footer.AddRange(footerValues); return this; } + private static string FormatFooterValue(DataTableColumn column, object value) + { + if (value is IFormattable formattable && column.FormatStyle.HasValue) + { + var style = column.FormatStyle.Value; + var precision = column.Precision ?? 2; + + if (style == NumberFormatStyle.Currency || style == NumberFormatStyle.Accounting) + { + var currency = column.Currency ?? "USD"; + var culture = GetCultureForCurrency(currency); + return formattable.ToString($"C{precision}", culture); + } + + var invariant = CultureInfo.InvariantCulture; + return style switch + { + NumberFormatStyle.Percent => formattable.ToString($"P{precision}", invariant), + NumberFormatStyle.Decimal => formattable.ToString($"N{precision}", invariant), + _ => value.ToString() ?? "" + }; + } + + return value.ToString() ?? ""; + } + + private static CultureInfo GetCultureForCurrency(string isoCurrencyCode) + { + try + { + foreach (var ci in CultureInfo.GetCultures(CultureTypes.SpecificCultures)) + { + var region = new RegionInfo(ci.Name); + if (string.Equals(region.ISOCurrencySymbol, isoCurrencyCode, StringComparison.OrdinalIgnoreCase)) + return ci; + } + } + catch + { + // Fall through to default + } + + // Default to en-US for unknown currencies + var fallback = new CultureInfo("en-US"); + fallback.NumberFormat.CurrencySymbol = isoCurrencyCode; + return fallback; + } + private List GetOrCreateFooterValueList( Expression> field, Func compiledSelector)