@using System.Dynamic
@using System.Runtime.CompilerServices
@using System.Text
@using Microsoft.CSharp.RuntimeBinder
@inject IJSRuntime JsRuntime
@*
* dbMango
*
* Copyright 2025 Deutsche Bank AG
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*@
@if (Filterable)
{
#
}
@ChildContent
@if (DisplayedItems.Any())
{
var ix = 1 + (_currentPage - 1) * PageSize;
@foreach (var item in DisplayedItems)
{
@ix
@foreach (var column in Columns)
{
@if (column.Template == null && !string.IsNullOrEmpty(column.Field))
{
var val = GetLambdaForValue(column.Field)(item);
@foreach (var column in Columns)
{
if (column.ShowTotals)
{
var val = Totals.TryGet(column?.Name ?? "")?.Total;
@ConvertToString(val, column!.Format)
}
else
{
}
}
}
}
@TableFooter
@code {
public enum Direction
{
Back,
Previous,
Next,
Forward
}
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public RenderFragment? TableFooter { get; set; }
[Parameter]
public IReadOnlyCollection Items
{
get;
set
{
//detect if the new list is actually different, this is a little basic, but handles deletes/adds
//to the list. Ideally a hash of the list is best, but because 'StateHasChanged' is being called so many times
//by some controls (ContextControl!) this logic
if (value == null || (value.Count == _itemCount && Equals(field, value)))
return;
field = value;
_itemCount = field.Count;
ActiveFilters.Clear();
ActiveFiltersExactMatch.Clear();
//reapply the filtering to the new items
Task.Run(ApplyColumnFilterAndSort);
}
} = [];
[Parameter] public int PageSize { get; set; } = 10;
[Parameter] public int PagerSize { get; set; } = 5;
[Parameter] public string? Class { get; set; }
[Parameter] public string? TableClass { get; set; }
[Parameter] public bool Filterable { get; set; }
[Parameter] public bool Selectable { get; set; }
[Parameter] public dynamic? SelectedItem { get; set; }
[Parameter] public string? SelectedColumn { get; set; }
[Parameter] public EventCallback<(dynamic row, string col)> SelectionChanged { get; set; }
[Parameter] public Func RowClass { get; set; } = _ => "";
[Parameter] public bool AbsoluteSort { get; set; } = true;
[Parameter] public IMinMaxCache? Totals { get; set; }
private string UniqueID { get;set;} = Guid.NewGuid().ToString();
private int _itemCount;
///
/// Allow the user to specify a custom class per cell, this will completely override the default styling
///
[Parameter] public Func CellClass { get; set; } = DefaultGetCellClass;
///
/// Allow the user to specify a custom style per cell, this will be applied on top of normal styling
///
[Parameter] public Func CellStyle { get; set; } = (_,_) => "";
///
/// Allows the user to apply _Extra_ styling on top of the existing default styling
///
[Parameter] public Func? GetCellClassCallback { get; set; }
[Parameter]
public List FilteredItems { get; set; } = [];
[Parameter]
public EventCallback> FilteredItemsChanged { get; set; }
[Parameter]
public string? CurrentSortColumn
{
get;
set
{
if (field == value)
return;
field = value;
CurrentSortColumnChanged.InvokeAsync(field);
}
} = null;
[Parameter]
public EventCallback CurrentSortColumnChanged { get; set; }
public enum SortModeType
{
NoSort,
Ascending,
Descending,
AscendingAbsolute,
DescendingAbsolute
}
[Parameter]
public SortModeType SortMode
{
get;
set
{
if (field == value)
return;
field = value;
SortModeChanged.InvokeAsync(field);
}
} = SortModeType.NoSort;
[Parameter]
public EventCallback SortModeChanged { get; set; }
//Column the table is currently sorted by.
protected IEnumerable DisplayedItems => FilteredItems?.Skip((_currentPage - 1) * PageSize).Take(PageSize) ?? [];
private int _totalPages;
private int _currentPage = 1;
private int _startPage;
private int _endPage;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (Items == null)
return;
await ApplyColumnFilterAndSort();
}
}
private async Task UpdatePage(int page)
{
_currentPage = page;
_totalPages = (int)Math.Ceiling((FilteredItems?.Count ?? 0) / (decimal)PageSize);
var sideWidth = (int)Math.Floor((PagerSize - 1) / 2d);
_startPage = _currentPage - sideWidth >= 1 ? _currentPage - sideWidth : 1;
_endPage = _currentPage + sideWidth <= _totalPages ? _currentPage + sideWidth : _totalPages;
await InvokeAsync(StateHasChanged);
}
#region sorting
//Direction the table is currently sorted by
///
/// Returns a function that given a row item, will return the value for a specified field
///
///
///
public static Func GetLambdaForValue(string? field)
{
return i =>
{
if (string.IsNullOrEmpty(field))
{
return "";
}
switch (i)
{
case IDictionary dict:
{
dict.TryGetValue(field, out var value);
return value;
}
case IDictionary dict1:
{
dict1.TryGetValue(field, out var value);
return value;
}
case DynamicObject dyn:
{
return GetDynamicMember(dyn, field);
}
default:
return i?.GetType().GetProperty(field)?.GetValue(i, null);
}
};
}
///
/// Returns a function that given a row item, will return the absolute value for a specified field
///
///
///
public static Func GetLambdaForAbsValue(string? field)
{
var getValue = GetLambdaForValue(field);
return x =>
{
var val = getValue(x);
return val switch
{
double d => Math.Abs(d),
int i => Math.Abs(i),
long l => Math.Abs(l),
float f => Math.Abs(f),
_ => val
};
};
}
public static bool IsNumeric(object value) => value is double ||
value is int ||
value is long ||
value is uint ||
value is ulong;
///
/// This is a rather weird way around the ability to create an Icomparer of dynamic
///
///
public abstract class GridComparerDynamic : IComparer
{
public abstract int Compare(T? row1, T? row2);
}
public class GridComparer(Func methodLambda) : GridComparerDynamic
{
public override int Compare(object? row1, object? row2)
{
var row1Value = methodLambda(row1);
var row2Value = methodLambda(row2);
if (row1Value == null && row2Value == null) return 0;
//put any nulls at the end of the list
if (row1Value == null) return -1;
if (row2Value == null) return 1;
//its possible for the type to be different here
if (row1Value.GetType() != row2Value.GetType())
{
//if both types are at least numberic, then compare them as doubles
if (IsNumeric(row1Value) && IsNumeric(row2Value))
return ((double)row1Value).CompareTo((double)row2Value);
//fall back to comparing as strings
return row1Value.ToString().CompareTo(row2Value.ToString());
}
if (row1Value is IComparable)
return row1Value.CompareTo(row2Value);
return 0;
}
}
#endregion
#region Filtering
protected readonly Dictionary> ActiveFilters = new();
protected readonly Dictionary ActiveFiltersExactMatch = new();
public async Task SetColumnFilter(string columnName, List filterValue, bool exactMatch = false)
{
if (ActiveFilters.ContainsKey(columnName) &&
ActiveFilters[columnName] == filterValue &&
ActiveFiltersExactMatch.ContainsKey(columnName) &&
ActiveFiltersExactMatch[columnName] == exactMatch)
return;
ActiveFilters[columnName] = filterValue;
ActiveFiltersExactMatch[columnName] = exactMatch;
if (filterValue.Count == 0)
{
ActiveFilters.Remove(columnName);
ActiveFiltersExactMatch.Remove(columnName);
}
_currentPage = 1;
await ApplyColumnFilterAndSort();
}
public async Task SetColumnSort(string columnName)
{
//Sorting against a column that is not currently sorted against.
if (columnName != CurrentSortColumn)
{
//Force order on the new column
CurrentSortColumn = columnName;
SortMode = AbsoluteSort
? SortModeType.DescendingAbsolute
: SortModeType.Descending;
}
else //Sorting against same column but in different direction
{
if (AbsoluteSort)
SortMode = SortMode switch
{
SortModeType.AscendingAbsolute => SortModeType.DescendingAbsolute,
SortModeType.DescendingAbsolute => SortModeType.AscendingAbsolute,
_ => SortModeType.DescendingAbsolute
};
else
SortMode = SortMode switch
{
SortModeType.Ascending => SortModeType.Descending,
SortModeType.Descending => SortModeType.Ascending,
_ => SortModeType.Descending
};
}
await ApplyColumnFilterAndSort();
}
private async Task ApplyColumnFilterAndSort()
{
IEnumerable fa = Items; // no copying needed
//create data filtering linq
foreach (var (column, value) in ActiveFilters)
{
var exactMatch = ActiveFiltersExactMatch[column];
fa = fa.Where(x =>
{
var tableColumnDef = Columns.FirstOrDefault(c => c.Field == column);
var colOriginalValue = GetLambdaForValue(column)(x);
var colValue = ConvertToString(colOriginalValue, tableColumnDef?.Format);
if (colValue != null)
{
var res = exactMatch
? value.Any(v => colValue.Equals(v))
: value.Any(v => colValue.Contains(v, StringComparison.OrdinalIgnoreCase));
return res;
}
return false;
});
}
//add data sorting on top
fa = SortMode switch
{
SortModeType.NoSort => fa,
SortModeType.Ascending => fa.OrderBy (x => x, new GridComparer(GetLambdaForValue(CurrentSortColumn))),
SortModeType.Descending => fa.OrderByDescending(x => x, new GridComparer(GetLambdaForValue(CurrentSortColumn))),
SortModeType.AscendingAbsolute => fa.OrderBy (x => x, new GridComparer(GetLambdaForAbsValue(CurrentSortColumn))),
SortModeType.DescendingAbsolute => fa.OrderByDescending(x => x, new GridComparer(GetLambdaForAbsValue(CurrentSortColumn))),
_ => throw new ArgumentOutOfRangeException()
};
FilteredItems = [..fa];
try
{
await InvokeAsync(() => FilteredItemsChanged.InvokeAsync(FilteredItems));
}
catch (Exception)
{
// ignore
}
await UpdatePage(_currentPage);
}
#endregion
#region pagination
public async Task NavigateToPage(Direction direction)
{
var page = _currentPage;
switch (direction)
{
case Direction.Next:
{
if (page < _totalPages)
{
page += 1;
}
break;
}
case Direction.Previous:
{
if (page > 1)
{
page -= 1;
}
break;
}
case Direction.Back:
{
page = 1;
break;
}
case Direction.Forward:
{
page = _totalPages;
break;
}
}
await UpdatePage(page);
}
public async Task NavigateToPage(int page)
{
if (page < 1)
{
page = 1;
}
else if (page > _totalPages)
{
page = _totalPages;
}
await UpdatePage(page);
}
#endregion
List Columns { get; set; } = [];
public void AddColumn(TableColumnControl column)
{
Columns.Add(column);
}
public void RemoveColumn(TableColumnControl column)
{
Columns.Remove(column);
}
public static string ConvertToString(object? value, string? format)
{
if (value == null)
return "";
if (value is string s)
return s;
if (format == null)
return value.ToString() ?? "";
return value switch
{
double d => d .ToString( format ),
float f => f .ToString( format ),
long l => l .ToString( format ),
int i => i .ToString( format ),
TimeSpan ts => ts .ToString( format == "N0" ? "g" : format),
decimal dc => dc .ToString( format ),
DateTime dt => DateTimeFormatting(dt, format),
_ => value.ToString() ?? ""
};
}
private static string DateTimeFormatting(DateTime dt, string format)
{
if ( format == "N2" )
{
return dt == DateTime.MinValue ? "" : dt.ToString("yyyy-MM-dd HH:mm:ss");
}
return format == "N1" && dt == DateTime.MinValue ? "" :
dt.ToString((format == "N0" || format == "N1") ? "yyyy-MM-dd" : format);
}
public static string DefaultGetCellClass(dynamic item, TableColumnControl column)
{
if (column?.Field == null)
return "";
var val = GetLambdaForValue(column.Field)(item);
return GetCellClass(item, val, column);
}
public static string GetCellClass(dynamic row, object? value, TableColumnControl column)
{
var c = column.Class;
var s = "";
var isZero = false;
var isNegative = false;
var isPositive = false;
var customStyle = "";
switch (value)
{
case double.NaN:
break;
case null:
break;
case double n:
isZero = n == 0.0;
isNegative = n < 0.0;
isPositive = n > 0.0;
break;
case int n:
isZero = n == 0.0;
isNegative = n < 0.0;
isPositive = n > 0.0;
break;
case long n:
isZero = n == 0L;
isNegative = n < 0L;
isPositive = n > 0L;
break;
case float n:
isZero = n == 0.0;
isNegative = n < 0.0;
isPositive = n > 0.0;
break;
case decimal d:
isZero = d == 0.0M;
isNegative = d < 0.0M;
isPositive = d > 0.0M;
break;
default: //string value
s = value.ToString();
break;
}
if (column.TableControl?.GetCellClassCallback != null)
{
customStyle = column.TableControl.GetCellClassCallback(row, column);
}
return (s?.Equals("") ?? false) switch
{
true when isZero => $"align-right {c} {column.ZeroValueClass} {customStyle}",
true when isNegative => $"align-right {c} {column.NegativeValueClass} {customStyle}",
true when isPositive => $"align-right {c} {column.PositiveValueClass} {customStyle}",
_ => $"align-left {c}{customStyle}"
};
}
private async Task OnSelectionChanged(dynamic selection, string? field)
{
SelectedItem = selection;
SelectedColumn = field;
if (Selectable)
{
await SelectionChanged.InvokeAsync((selection, field ?? ""));
}
}
public static object? GetDynamicMember(object obj, string memberName)
{
var binder = Binder.GetMember(CSharpBinderFlags.None, memberName, obj.GetType(),
[CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null)]);
var callsite = CallSite>.Create(binder);
try
{
return callsite.Target(callsite, obj);
}
catch (Exception)
{
return null;
}
}
private void TriggerFilterRow()
{
Filterable = !Filterable;
}
private async Task OnCopyAllToClipboard()
{
string Shield(string s) => s.Contains(",") || s.Contains("\n") ? $"\"{s}\"" : s;
var str = new StringBuilder();
str.Append(string.Join(",", Columns.Select(col => Shield(col.Name ?? ""))));
str.AppendLine();
foreach (var row in FilteredItems)
{
str.Append(string.Join(",", Columns.Select(col =>
{
var colOriginalValue = GetLambdaForValue(col.Field)(row);
var colValue = ConvertToString(colOriginalValue, col.Format);
return Shield(colValue);
})));
str.AppendLine();
}
await JsRuntime.InvokeVoidAsync("DashboardUtils.CopyToClipboard", str.ToString());
}
}