Alexander Shabarshov 2a7a24c9e7 Initial contribution
2025-11-03 14:43:26 +00:00

764 lines
25 KiB
Plaintext

@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.
*@
<style>
.tc-row-number {
color: @Night.secondary;
}
.align-left {
text-align: left;
}
.align-right {
text-align: right;
}
.totals-row {
font-weight: bold;
background-color: #305060;
}
</style>
<div class="mb-0 @Class">
<table class="@TableClass">
<thead>
<tr>
<th style="width: 25px !important;" class="p-0">
<div class="w-100 h-100 d-flex flex-column">
<button class="btn px-0 w-100 text-left" @onclick="TriggerFilterRow">
<span class="m-auto ui-icon-font ui-icon-sm icon-search-sm"></span>
</button>
@if (Filterable)
{
<span class="m-auto">#</span>
}
</div>
</th>
<CascadingValue Value="this">
@ChildContent
</CascadingValue>
</tr>
</thead>
<tbody>
@if (DisplayedItems.Any())
{
var ix = 1 + (_currentPage - 1) * PageSize;
@foreach (var item in DisplayedItems)
{
<tr class="@(RowClass(item))">
<td class="tc-row-number">
@ix
</td>
@foreach (var column in Columns)
{
@if (column.Template == null && !string.IsNullOrEmpty(column.Field))
{
var val = GetLambdaForValue(column.Field)(item);
<td class="@column.Class @(CellClass(item, column))"
style="@(CellStyle(item, column))"
@onclick="@(() => OnSelectionChanged(item, column.Field))"
>
@ConvertToString(val, column.Format)
</td>
}
else if (column.Template != null)
{
<td class="@column.Class @(CellClass(item, column))"
style="@(CellStyle(item, column))"
@onclick="@(() => OnSelectionChanged(item, column.Field))">
@column.Template((item, column))
</td>
}
else
{
<td class="@column.Class @(CellClass(item, column))">
???
</td>
}
}
</tr>
ix++;
}
@if (Totals != null)
{
<tr>
<td class="totals-row">
Total
</td>
@foreach (var column in Columns)
{
if (column.ShowTotals)
{
var val = Totals.TryGet(column?.Name ?? "")?.Total;
<td class="@(GetCellClass("", val, column!)) totals-row">
@ConvertToString(val, column!.Format)
</td>
}
else
{
<td class="totals-row">
&nbsp;
</td>
}
}
</tr>
}
}
</tbody>
<tfoot>
<tr>@TableFooter</tr>
</tfoot>
</table>
<nav aria-label="Page navigation">
<ul class="pagination ag-wadk ag-paging-panel mt-1" style="align-items: center; justify-content: flex-start;">
@if (PageSize <= Items?.Count)
{
<li class="page-item">
<a class="page-link cursor-pointer" @onclick=@(() => NavigateToPage(Direction.Back))>&laquo;</a>
</li>
<li class="page-item">
<a class="page-link cursor-pointer" @onclick=@(() => NavigateToPage(Direction.Previous))>Previous</a>
</li>
@for (var i = _startPage; i <= _endPage; i++)
{
var currentPage = i;
<li class="page-item @(currentPage==_currentPage?"active":"")">
<a class="page-link cursor-pointer" @onclick=@(() => NavigateToPage(currentPage))>
@currentPage
</a>
</li>
}
<li class="page-item">
<a class="page-link cursor-pointer" @onclick=@(() => NavigateToPage(Direction.Next))>Next</a>
</li>
<li class="page-item">
<a class="page-link cursor-pointer" @onclick=@(() => NavigateToPage(Direction.Forward))>&raquo;</a>
</li>
}
<li class="page-item ml-2" style="color: @Night.secondary">
@(DisplayedItems?.Count() ?? 0) of total @Items?.Count
</li>
<li class="page-item ml-2 flex-pull-right">
<a class="page-link cursor-pointer" @onclick="@OnCopyAllToClipboard">
<span class="ui-icon-font icon-duplicate-document-sm"></span>
</a>
</li>
</ul>
</nav>
</div>
@code {
public enum Direction
{
Back,
Previous,
Next,
Forward
}
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public RenderFragment? TableFooter { get; set; }
[Parameter]
public IReadOnlyCollection<dynamic> 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<dynamic, string> 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;
/// <summary>
/// Allow the user to specify a custom class per cell, this will completely override the default styling
/// </summary>
[Parameter] public Func<dynamic, TableColumnControl, string> CellClass { get; set; } = DefaultGetCellClass;
/// <summary>
/// Allow the user to specify a custom style per cell, this will be applied on top of normal styling
/// </summary>
[Parameter] public Func<dynamic, TableColumnControl, string> CellStyle { get; set; } = (_,_) => "";
/// <summary>
/// Allows the user to apply _Extra_ styling on top of the existing default styling
/// </summary>
[Parameter] public Func<dynamic, TableColumnControl, string>? GetCellClassCallback { get; set; }
[Parameter]
public List<dynamic> FilteredItems { get; set; } = [];
[Parameter]
public EventCallback<List<dynamic>> FilteredItemsChanged { get; set; }
[Parameter]
public string? CurrentSortColumn
{
get;
set
{
if (field == value)
return;
field = value;
CurrentSortColumnChanged.InvokeAsync(field);
}
} = null;
[Parameter]
public EventCallback<string> 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<SortModeType> SortModeChanged { get; set; }
//Column the table is currently sorted by.
protected IEnumerable<dynamic> 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
/// <summary>
/// Returns a function that given a row item, will return the value for a specified field
/// </summary>
/// <param name="field"></param>
/// <returns></returns>
public static Func<dynamic?, dynamic?> GetLambdaForValue(string? field)
{
return i =>
{
if (string.IsNullOrEmpty(field))
{
return "";
}
switch (i)
{
case IDictionary<string, object> dict:
{
dict.TryGetValue(field, out var value);
return value;
}
case IDictionary<string, string> 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);
}
};
}
/// <summary>
/// Returns a function that given a row item, will return the absolute value for a specified field
/// </summary>
/// <param name="field"></param>
/// <returns></returns>
public static Func<dynamic?, dynamic?> 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;
/// <summary>
/// This is a rather weird way around the ability to create an Icomparer of dynamic
/// </summary>
/// <typeparam name="T"></typeparam>
public abstract class GridComparerDynamic<T> : IComparer<T>
{
public abstract int Compare(T? row1, T? row2);
}
public class GridComparer(Func<dynamic?, dynamic?> methodLambda) : GridComparerDynamic<dynamic>
{
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<string, List<string>> ActiveFilters = new();
protected readonly Dictionary<string, bool> ActiveFiltersExactMatch = new();
public async Task SetColumnFilter(string columnName, List<string> 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<dynamic> 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<TableColumnControl> 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<Func<CallSite, object, object>>.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());
}
}