Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
103 changes: 103 additions & 0 deletions src/Ivy.Tests/Views/DataTables/DataTableFooterFormatTests.cs
Original file line number Diff line number Diff line change
@@ -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<SalesRow> SampleData() =>
new[]
{
new SalesRow("A", 10_543.56m, 0.152, 100),
new SalesRow("B", 150_587.95m, 0.348, 200),
}.AsQueryable();

private static List<string> GetFooter(DataTableBuilder<SalesRow> 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<IEnumerable<decimal>, 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]);
}
}
53 changes: 51 additions & 2 deletions src/Ivy/Views/DataTables/DataTableBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq.Expressions;
using System.Reflection;
using Ivy.Core.Helpers;
Expand Down Expand Up @@ -182,7 +183,7 @@ public DataTableBuilder<TModel> Footer<TValue>(
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;
Expand All @@ -196,13 +197,61 @@ public DataTableBuilder<TModel> Footer<TValue>(
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<TValue> GetOrCreateFooterValueList<TValue>(
Expression<Func<TModel, TValue>> field,
Func<TModel, TValue> compiledSelector)
Expand Down
Loading