Add URL link support to grid columns and info panel

- Introduced UrlLink parameter to MgGridDataColumn for clickable cell links with property placeholder support.
- Updated MgGridInfoPanel to render links for columns with UrlLink.
- Added unit tests for UrlLink rendering and value replacement.
- Added DynamicColumnAddingEventArgs and OnDynamicColumnAttributeAdding for dynamic column customization.
- Refactored layout storage key logic and enabled persistent info panel splitter size in MgGridWithInfoPanel.
- Updated product/order grids to use MgGridDataColumn with UrlLink and switched ProductDtos to AcObservableCollection for reactivity.
- Added AddPartner method to IFruitBankDataControllerCommon and FruitBankSignalRClient.
- Miscellaneous fixes: logger initialization, code cleanup, and improved comments.
This commit is contained in:
Loretta 2025-12-22 14:37:55 +01:00
parent bad4e50c17
commit 4dbeb507fe
6 changed files with 273 additions and 24 deletions

View File

@ -1,3 +1,4 @@
using AyCode.Blazor.Components.Components.Grids;
using AyCode.Core.Tests.TestModels; using AyCode.Core.Tests.TestModels;
using AyCode.Services.Server.Tests.SignalRs; using AyCode.Services.Server.Tests.SignalRs;
using AyCode.Services.SignalRs; using AyCode.Services.SignalRs;
@ -50,20 +51,29 @@ public class MgGridBaseTests : BunitTestContext
[TestCleanup] [TestCleanup]
public void TestTeardown() => Context?.Dispose(); public void TestTeardown() => Context?.Dispose();
private IRenderedComponent<TestMgGridOrderItem> RenderTestGrid(string autoSaveLayoutName = "TestGrid") private IRenderedComponent<TestMgGridOrderItem> RenderTestGrid(
string autoSaveLayoutName = "TestGrid",
System.Action<DynamicColumnAddingEventArgs>? onDynamicColumnAttributeAdding = null)
{ {
var dataSource = new TestGridOrderItemDataSource(_client, _crudTags); var dataSource = new TestGridOrderItemDataSource(_client, _crudTags);
return Context!.Render<TestMgGridOrderItem>(parameters => parameters return Context!.Render<TestMgGridOrderItem>(parameters =>
.Add(p => p.DataSource, dataSource) {
.Add(p => p.Logger, _logger) parameters
.Add(p => p.SignalRClient, _client) .Add(p => p.DataSource, dataSource)
.Add(p => p.AutoSaveLayoutName, autoSaveLayoutName) .Add(p => p.Logger, _logger)
.Add(p => p.GetAllMessageTag, _crudTags.GetAllMessageTag) .Add(p => p.SignalRClient, _client)
.Add(p => p.AddMessageTag, _crudTags.AddMessageTag) .Add(p => p.AutoSaveLayoutName, autoSaveLayoutName)
.Add(p => p.UpdateMessageTag, _crudTags.UpdateMessageTag) .Add(p => p.GetAllMessageTag, _crudTags.GetAllMessageTag)
.Add(p => p.RemoveMessageTag, _crudTags.RemoveMessageTag) .Add(p => p.AddMessageTag, _crudTags.AddMessageTag)
); .Add(p => p.UpdateMessageTag, _crudTags.UpdateMessageTag)
.Add(p => p.RemoveMessageTag, _crudTags.RemoveMessageTag);
if (onDynamicColumnAttributeAdding != null)
{
parameters.Add(p => p.OnDynamicColumnAttributeAdding, onDynamicColumnAttributeAdding);
}
});
} }
#region Layout Persistence Tests #region Layout Persistence Tests
@ -178,4 +188,39 @@ public class MgGridBaseTests : BunitTestContext
} }
#endregion #endregion
#region MgGridDataColumn UrlLink Tests
[TestMethod]
public async Task MgGridDataColumn_UrlLink_ShouldRenderLinkWithReplacedValues()
{
// Arrange - Render grid with UrlLink on Id column
var cut = RenderTestGrid(onDynamicColumnAttributeAdding: args =>
{
if (args.FieldName == nameof(TestOrderItem.Id))
{
args.AdditionalAttributes[nameof(MgGridDataColumn.UrlLink)] = "https://example.com/edit/{Id}";
}
});
// Wait for data source to load
await cut.Instance.WaitForDataSourceLoadedAsync();
// Get the first row's Id value from the grid
var firstRowId = cut.Instance.GetRowValue(0, nameof(TestOrderItem.Id));
Assert.IsNotNull(firstRowId, "First row should have an Id value");
// Build the expected URL with the actual Id value
var expectedUrl = $"https://example.com/edit/{firstRowId}";
// Find the anchor element with the exact expected href
var anchor = cut.Find($"a[href=\"{expectedUrl}\"]");
// Assert - The anchor should exist and its text content should be the Id value
Assert.IsNotNull(anchor, $"Should find anchor with href='{expectedUrl}'");
Assert.AreEqual(firstRowId.ToString(), anchor.TextContent,
$"Anchor text should be the Id value '{firstRowId}', but was '{anchor.TextContent}'");
}
#endregion
} }

View File

@ -10,6 +10,7 @@ using AyCode.Services.SignalRs;
using DevExpress.Blazor; using DevExpress.Blazor;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using System.Reflection; using System.Reflection;
using Microsoft.AspNetCore.Components.Rendering;
namespace AyCode.Blazor.Components.Tests.Grids; namespace AyCode.Blazor.Components.Tests.Grids;
@ -28,6 +29,22 @@ public class TestGridOrderItemDataSource : TestOrderItemObservableDataSource
} }
} }
/// <summary>
/// Event args for dynamic column adding event.
/// Provides a delegate to add custom attributes to the column.
/// </summary>
public class DynamicColumnAddingEventArgs
{
public required string FieldName { get; init; }
public required PropertyInfo PropertyInfo { get; init; }
/// <summary>
/// Dictionary of additional attributes to add to the column.
/// Key is the attribute name, value is the attribute value.
/// </summary>
public Dictionary<string, object?> AdditionalAttributes { get; } = new();
}
/// <summary> /// <summary>
/// Base test implementation of MgGridBase for testing grid functionality. /// Base test implementation of MgGridBase for testing grid functionality.
/// Overrides layout persistence to use in-memory storage for testing. /// Overrides layout persistence to use in-memory storage for testing.
@ -54,6 +71,13 @@ public abstract class TestMgGridBase<TSignalRDataSource, TDataItem, TId, TLogger
/// </summary> /// </summary>
public bool IsDataSourceLoaded { get; private set; } public bool IsDataSourceLoaded { get; private set; }
/// <summary>
/// Event called when a dynamic column is being added. Allows customization of column properties.
/// Add attributes to eventArgs.AdditionalAttributes dictionary.
/// </summary>
[Parameter]
public Action<DynamicColumnAddingEventArgs>? OnDynamicColumnAttributeAdding { get; set; }
public void SetTestUserId(int userId) => _testUserId = userId; public void SetTestUserId(int userId) => _testUserId = userId;
public int GetTestUserId() => _testUserId; public int GetTestUserId() => _testUserId;
protected override int GetLayoutUserId() => _testUserId; protected override int GetLayoutUserId() => _testUserId;
@ -101,7 +125,7 @@ public abstract class TestMgGridBase<TSignalRDataSource, TDataItem, TId, TLogger
/// <summary> /// <summary>
/// Builds grid columns from TDataItem properties using reflection /// Builds grid columns from TDataItem properties using reflection
/// </summary> /// </summary>
private void BuildColumnsFromDataItem(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) private void BuildColumnsFromDataItem(RenderTreeBuilder builder)
{ {
var properties = typeof(TDataItem).GetProperties(BindingFlags.Public | BindingFlags.Instance) var properties = typeof(TDataItem).GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && IsSimpleType(p.PropertyType)) .Where(p => p.CanRead && IsSimpleType(p.PropertyType))
@ -110,9 +134,25 @@ public abstract class TestMgGridBase<TSignalRDataSource, TDataItem, TId, TLogger
var seq = 0; var seq = 0;
foreach (var property in properties) foreach (var property in properties)
{ {
builder.OpenComponent<DxGridDataColumn>(seq++); // Create event args and invoke the event
builder.AddAttribute(seq++, nameof(DxGridDataColumn.FieldName), property.Name); var eventArgs = new DynamicColumnAddingEventArgs
builder.AddAttribute(seq++, nameof(DxGridDataColumn.Width), GetDefaultWidth(property.PropertyType)); {
FieldName = property.Name,
PropertyInfo = property
};
OnDynamicColumnAttributeAdding?.Invoke(eventArgs);
builder.OpenComponent<MgGridDataColumn>(seq++);
builder.AddAttribute(seq++, nameof(MgGridDataColumn.Name), property.Name);
builder.AddAttribute(seq++, nameof(MgGridDataColumn.FieldName), property.Name);
builder.AddAttribute(seq++, nameof(MgGridDataColumn.Width), GetDefaultWidth(property.PropertyType));
// Add additional attributes from the event
foreach (var attr in eventArgs.AdditionalAttributes)
{
builder.AddAttribute(seq++, attr.Key, attr.Value);
}
builder.CloseComponent(); builder.CloseComponent();
} }
} }

View File

@ -14,6 +14,7 @@ using System.ComponentModel;
using System.Reflection; using System.Reflection;
using System.Text.Json; using System.Text.Json;
using DevExpress.Blazor.Internal; using DevExpress.Blazor.Internal;
using System.Text.RegularExpressions;
namespace AyCode.Blazor.Components.Components.Grids; namespace AyCode.Blazor.Components.Components.Grids;
@ -60,6 +61,7 @@ public interface IMgGridBase : IGrid
/// Whether the grid/wrapper is currently in fullscreen mode /// Whether the grid/wrapper is currently in fullscreen mode
/// </summary> /// </summary>
bool IsFullscreen { get; } bool IsFullscreen { get; }
string LayoutStorageKey { get; }
/// <summary> /// <summary>
/// Toggles fullscreen mode for the grid (or wrapper if available) /// Toggles fullscreen mode for the grid (or wrapper if available)
@ -105,6 +107,15 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
return current; return current;
} }
public string LayoutStorageKey
{
get
{
var masterDetailName = IsMasterGrid ? "Master" : ParentDataItem!.GetType().Name;
return $"{AutoSaveLayoutName}_{masterDetailName}_AutoSave_{GetLayoutUserId()}";
}
}
/// <summary> /// <summary>
/// Reference to the wrapper component for grid-InfoPanel communication /// Reference to the wrapper component for grid-InfoPanel communication
/// </summary> /// </summary>
@ -682,6 +693,9 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
LayoutAutoLoading = Grid_LayoutAutoLoading; LayoutAutoLoading = Grid_LayoutAutoLoading;
LayoutAutoSaving = Grid_LayoutAutoSaving; LayoutAutoSaving = Grid_LayoutAutoSaving;
// Register this grid with the wrapper for splitter size persistence
GridWrapper?.RegisterGrid(this);
IsFirstInitializeParameters = true; IsFirstInitializeParameters = true;
} }
@ -697,16 +711,12 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
private async Task Grid_LayoutAutoLoading(GridPersistentLayoutEventArgs e) private async Task Grid_LayoutAutoLoading(GridPersistentLayoutEventArgs e)
{ {
var masterDetailName = IsMasterGrid ? "Master" : ParentDataItem!.GetType().Name; e.Layout = await LoadLayoutFromLocalStorageAsync(LayoutStorageKey);
var layoutKey = $"{AutoSaveLayoutName}_{masterDetailName}_AutoSave_{GetLayoutUserId()}";
e.Layout = await LoadLayoutFromLocalStorageAsync(layoutKey);
} }
private async Task Grid_LayoutAutoSaving(GridPersistentLayoutEventArgs e) private async Task Grid_LayoutAutoSaving(GridPersistentLayoutEventArgs e)
{ {
var masterDetailName = IsMasterGrid ? "Master" : ParentDataItem!.GetType().Name; await SaveLayoutToLocalStorageAsync(e.Layout, LayoutStorageKey);
var layoutKey = $"{AutoSaveLayoutName}_{masterDetailName}_AutoSave_{GetLayoutUserId()}";
await SaveLayoutToLocalStorageAsync(e.Layout, layoutKey);
} }
protected virtual async Task<GridPersistentLayout?> LoadLayoutFromLocalStorageAsync(string localStorageKey) protected virtual async Task<GridPersistentLayout?> LoadLayoutFromLocalStorageAsync(string localStorageKey)

View File

@ -1,5 +1,7 @@
using DevExpress.Blazor; using DevExpress.Blazor;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using System.Text.RegularExpressions;
namespace AyCode.Blazor.Components.Components.Grids; namespace AyCode.Blazor.Components.Components.Grids;
@ -8,6 +10,9 @@ namespace AyCode.Blazor.Components.Components.Grids;
/// </summary> /// </summary>
public class MgGridDataColumn : DxGridDataColumn public class MgGridDataColumn : DxGridDataColumn
{ {
private string? _urlLink;
private bool _isInitialized;
/// <summary> /// <summary>
/// Whether this column should be visible in the InfoPanel. Default is true. /// Whether this column should be visible in the InfoPanel. Default is true.
/// </summary> /// </summary>
@ -25,4 +30,64 @@ public class MgGridDataColumn : DxGridDataColumn
/// </summary> /// </summary>
[Parameter] [Parameter]
public int InfoPanelOrder { get; set; } = int.MaxValue; public int InfoPanelOrder { get; set; } = int.MaxValue;
/// <summary>
/// URL template with {property} placeholders that will be replaced with row values.
/// Example: https://shop.fruitbank.hu/Admin/Order/Edit/{Id}/{OrderId}
/// </summary>
[Parameter]
public string? UrlLink
{
get => _urlLink;
set
{
if (_urlLink == value) return;
_urlLink = value;
if (_isInitialized) UpdateCellDisplayTemplate();
}
}
protected override void OnParametersSet()
{
base.OnParametersSet();
_isInitialized = true;
UpdateCellDisplayTemplate();
}
private void UpdateCellDisplayTemplate()
{
if (!string.IsNullOrWhiteSpace(_urlLink))
{
CellDisplayTemplate = context => builder =>
{
var url = BuildUrlFromTemplate(_urlLink, context.DataItem);
builder.OpenElement(0, "a");
builder.AddAttribute(1, "href", url);
builder.AddAttribute(2, "target", "_blank");
builder.AddAttribute(3, "style", "text-decoration: none;");
builder.AddContent(4, context.DisplayText);
builder.CloseElement();
};
}
}
/// <summary>
/// Replaces {property} placeholders in the template with values from the data item.
/// Exposed for unit testing.
/// </summary>
internal static string BuildUrlFromTemplate(string template, object? dataItem)
{
if (dataItem == null) return template;
return Regex.Replace(template, "{([^}]+)}", match =>
{
var propName = match.Groups[1].Value;
var prop = dataItem.GetType().GetProperty(propName);
if (prop != null)
{
var value = prop.GetValue(dataItem);
return value?.ToString() ?? string.Empty;
}
return match.Value;
});
}
} }

View File

@ -115,7 +115,7 @@
} }
else else
{ {
RenderCellContent(value, displayText)(builder); RenderCellContent(value, displayText, column, dataItem)(builder);
} }
builder.CloseElement(); builder.CloseElement();
@ -359,11 +359,27 @@
builder.CloseComponent(); builder.CloseComponent();
} }
private RenderFragment RenderCellContent(object? value, string displayText) private RenderFragment RenderCellContent(object? value, string displayText, DxGridDataColumn? column = null, object? dataItem = null)
{ {
return builder => return builder =>
{ {
var seq = 0; var seq = 0;
// Check if column has UrlLink
if (column is MgGridDataColumn mgColumn && !string.IsNullOrWhiteSpace(mgColumn.UrlLink) && dataItem != null)
{
var url = MgGridDataColumn.BuildUrlFromTemplate(mgColumn.UrlLink, dataItem);
builder.OpenElement(seq++, "a");
builder.AddAttribute(seq++, "href", url);
builder.AddAttribute(seq++, "target", "_blank");
builder.AddAttribute(seq++, "class", "mg-info-panel-link");
builder.AddAttribute(seq++, "title", displayText);
builder.AddContent(seq++, displayText);
builder.CloseElement();
return;
}
builder.OpenElement(seq++, "span"); builder.OpenElement(seq++, "span");
builder.AddAttribute(seq++, "class", "mg-info-panel-value"); builder.AddAttribute(seq++, "class", "mg-info-panel-value");
builder.AddAttribute(seq++, "title", displayText); builder.AddAttribute(seq++, "title", displayText);

View File

@ -1,4 +1,5 @@
@using DevExpress.Blazor @using DevExpress.Blazor
@inject Microsoft.JSInterop.IJSRuntime JSRuntime
<CascadingValue Value="this"> <CascadingValue Value="this">
@if (_isFullscreen) @if (_isFullscreen)
@ -23,6 +24,8 @@
private IInfoPanelBase? _infoPanelInstance; private IInfoPanelBase? _infoPanelInstance;
private IMgGridBase? _currentGrid; private IMgGridBase? _currentGrid;
private bool _isFullscreen; private bool _isFullscreen;
private string _currentInfoPanelSize = "400px";
private bool _sizeLoaded;
/// <summary> /// <summary>
/// The grid content to display in the left pane /// The grid content to display in the left pane
@ -77,6 +80,12 @@
public void RegisterGrid(IMgGridBase grid) public void RegisterGrid(IMgGridBase grid)
{ {
_currentGrid = grid; _currentGrid = grid;
// Load saved size when grid is registered
if (!_sizeLoaded)
{
_ = LoadSavedSizeAsync();
}
} }
/// <summary> /// <summary>
@ -106,6 +115,69 @@
StateHasChanged(); StateHasChanged();
} }
private string GetStorageKey() => _currentGrid != null
? $"Splitter_{_currentGrid.LayoutStorageKey}"
: null!;
private async Task LoadSavedSizeAsync()
{
if (_currentGrid == null) return;
try
{
var storageKey = GetStorageKey();
var savedSize = await JSRuntime.InvokeAsync<string>("localStorage.getItem", storageKey);
if (!string.IsNullOrWhiteSpace(savedSize))
{
_currentInfoPanelSize = savedSize;
_sizeLoaded = true;
await InvokeAsync(StateHasChanged);
}
else
{
_currentInfoPanelSize = InfoPanelSize;
_sizeLoaded = true;
}
}
catch
{
// Mute exceptions for the server prerender stage
_currentInfoPanelSize = InfoPanelSize;
_sizeLoaded = true;
}
}
private async Task SaveSizeAsync(string size)
{
if (_currentGrid == null) return;
try
{
var storageKey = GetStorageKey();
await JSRuntime.InvokeVoidAsync("localStorage.setItem", storageKey, size);
}
catch
{
// Mute exceptions for the server prerender stage
}
}
private async Task OnInfoPanelSizeChanged(string newSize)
{
_currentInfoPanelSize = newSize;
await SaveSizeAsync(newSize);
}
protected override void OnParametersSet()
{
if (!_sizeLoaded)
{
_currentInfoPanelSize = InfoPanelSize;
}
base.OnParametersSet();
}
private RenderFragment RenderMainContent() => __builder => private RenderFragment RenderMainContent() => __builder =>
{ {
if (ShowInfoPanel) if (ShowInfoPanel)
@ -115,7 +187,8 @@
<DxSplitterPane> <DxSplitterPane>
@GridContent @GridContent
</DxSplitterPane> </DxSplitterPane>
<DxSplitterPane Size="@InfoPanelSize" MinSize="0px" MaxSize="100%" AllowCollapse="true" CssClass="mg-info-panel-pane"> <DxSplitterPane Size="@_currentInfoPanelSize" MinSize="0px" MaxSize="100%" AllowCollapse="false" CssClass="mg-info-panel-pane"
SizeChanged="OnInfoPanelSizeChanged">
@if (ChildContent != null) @if (ChildContent != null)
{ {
@ChildContent @ChildContent