@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. *@
@ChildContent @if (DisplayedItems.Any()) { var ix = 1 + (_currentPage - 1) * PageSize; @foreach (var item in DisplayedItems) { @foreach (var column in Columns) { @if (column.Template == null && !string.IsNullOrEmpty(column.Field)) { var val = GetLambdaForValue(column.Field)(item); } else if (column.Template != null) { } else { } } ix++; } @if (Totals != null) { @foreach (var column in Columns) { if (column.ShowTotals) { var val = Totals.TryGet(column?.Name ?? "")?.Total; } else { } } } } @TableFooter
@if (Filterable) { # }
@ix @ConvertToString(val, column.Format) @column.Template((item, column)) ???
Total @ConvertToString(val, column!.Format)  
@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()); } }