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:
parent
bad4e50c17
commit
4dbeb507fe
|
|
@ -1,3 +1,4 @@
|
|||
using AyCode.Blazor.Components.Components.Grids;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using AyCode.Services.Server.Tests.SignalRs;
|
||||
using AyCode.Services.SignalRs;
|
||||
|
|
@ -50,20 +51,29 @@ public class MgGridBaseTests : BunitTestContext
|
|||
[TestCleanup]
|
||||
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);
|
||||
|
||||
return Context!.Render<TestMgGridOrderItem>(parameters => parameters
|
||||
.Add(p => p.DataSource, dataSource)
|
||||
.Add(p => p.Logger, _logger)
|
||||
.Add(p => p.SignalRClient, _client)
|
||||
.Add(p => p.AutoSaveLayoutName, autoSaveLayoutName)
|
||||
.Add(p => p.GetAllMessageTag, _crudTags.GetAllMessageTag)
|
||||
.Add(p => p.AddMessageTag, _crudTags.AddMessageTag)
|
||||
.Add(p => p.UpdateMessageTag, _crudTags.UpdateMessageTag)
|
||||
.Add(p => p.RemoveMessageTag, _crudTags.RemoveMessageTag)
|
||||
);
|
||||
return Context!.Render<TestMgGridOrderItem>(parameters =>
|
||||
{
|
||||
parameters
|
||||
.Add(p => p.DataSource, dataSource)
|
||||
.Add(p => p.Logger, _logger)
|
||||
.Add(p => p.SignalRClient, _client)
|
||||
.Add(p => p.AutoSaveLayoutName, autoSaveLayoutName)
|
||||
.Add(p => p.GetAllMessageTag, _crudTags.GetAllMessageTag)
|
||||
.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
|
||||
|
|
@ -178,4 +188,39 @@ public class MgGridBaseTests : BunitTestContext
|
|||
}
|
||||
|
||||
#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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ using AyCode.Services.SignalRs;
|
|||
using DevExpress.Blazor;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
|
||||
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>
|
||||
/// Base test implementation of MgGridBase for testing grid functionality.
|
||||
/// Overrides layout persistence to use in-memory storage for testing.
|
||||
|
|
@ -54,6 +71,13 @@ public abstract class TestMgGridBase<TSignalRDataSource, TDataItem, TId, TLogger
|
|||
/// </summary>
|
||||
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 int GetTestUserId() => _testUserId;
|
||||
protected override int GetLayoutUserId() => _testUserId;
|
||||
|
|
@ -101,7 +125,7 @@ public abstract class TestMgGridBase<TSignalRDataSource, TDataItem, TId, TLogger
|
|||
/// <summary>
|
||||
/// Builds grid columns from TDataItem properties using reflection
|
||||
/// </summary>
|
||||
private void BuildColumnsFromDataItem(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder)
|
||||
private void BuildColumnsFromDataItem(RenderTreeBuilder builder)
|
||||
{
|
||||
var properties = typeof(TDataItem).GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanRead && IsSimpleType(p.PropertyType))
|
||||
|
|
@ -110,9 +134,25 @@ public abstract class TestMgGridBase<TSignalRDataSource, TDataItem, TId, TLogger
|
|||
var seq = 0;
|
||||
foreach (var property in properties)
|
||||
{
|
||||
builder.OpenComponent<DxGridDataColumn>(seq++);
|
||||
builder.AddAttribute(seq++, nameof(DxGridDataColumn.FieldName), property.Name);
|
||||
builder.AddAttribute(seq++, nameof(DxGridDataColumn.Width), GetDefaultWidth(property.PropertyType));
|
||||
// Create event args and invoke the event
|
||||
var eventArgs = new DynamicColumnAddingEventArgs
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ using System.ComponentModel;
|
|||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using DevExpress.Blazor.Internal;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AyCode.Blazor.Components.Components.Grids;
|
||||
|
||||
|
|
@ -60,6 +61,7 @@ public interface IMgGridBase : IGrid
|
|||
/// Whether the grid/wrapper is currently in fullscreen mode
|
||||
/// </summary>
|
||||
bool IsFullscreen { get; }
|
||||
string LayoutStorageKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Toggles fullscreen mode for the grid (or wrapper if available)
|
||||
|
|
@ -105,6 +107,15 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
|||
return current;
|
||||
}
|
||||
|
||||
public string LayoutStorageKey
|
||||
{
|
||||
get
|
||||
{
|
||||
var masterDetailName = IsMasterGrid ? "Master" : ParentDataItem!.GetType().Name;
|
||||
return $"{AutoSaveLayoutName}_{masterDetailName}_AutoSave_{GetLayoutUserId()}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the wrapper component for grid-InfoPanel communication
|
||||
/// </summary>
|
||||
|
|
@ -682,6 +693,9 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
|||
LayoutAutoLoading = Grid_LayoutAutoLoading;
|
||||
LayoutAutoSaving = Grid_LayoutAutoSaving;
|
||||
|
||||
// Register this grid with the wrapper for splitter size persistence
|
||||
GridWrapper?.RegisterGrid(this);
|
||||
|
||||
IsFirstInitializeParameters = true;
|
||||
}
|
||||
|
||||
|
|
@ -697,16 +711,12 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
|||
|
||||
private async Task Grid_LayoutAutoLoading(GridPersistentLayoutEventArgs e)
|
||||
{
|
||||
var masterDetailName = IsMasterGrid ? "Master" : ParentDataItem!.GetType().Name;
|
||||
var layoutKey = $"{AutoSaveLayoutName}_{masterDetailName}_AutoSave_{GetLayoutUserId()}";
|
||||
e.Layout = await LoadLayoutFromLocalStorageAsync(layoutKey);
|
||||
e.Layout = await LoadLayoutFromLocalStorageAsync(LayoutStorageKey);
|
||||
}
|
||||
|
||||
private async Task Grid_LayoutAutoSaving(GridPersistentLayoutEventArgs e)
|
||||
{
|
||||
var masterDetailName = IsMasterGrid ? "Master" : ParentDataItem!.GetType().Name;
|
||||
var layoutKey = $"{AutoSaveLayoutName}_{masterDetailName}_AutoSave_{GetLayoutUserId()}";
|
||||
await SaveLayoutToLocalStorageAsync(e.Layout, layoutKey);
|
||||
await SaveLayoutToLocalStorageAsync(e.Layout, LayoutStorageKey);
|
||||
}
|
||||
|
||||
protected virtual async Task<GridPersistentLayout?> LoadLayoutFromLocalStorageAsync(string localStorageKey)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using DevExpress.Blazor;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AyCode.Blazor.Components.Components.Grids;
|
||||
|
||||
|
|
@ -8,6 +10,9 @@ namespace AyCode.Blazor.Components.Components.Grids;
|
|||
/// </summary>
|
||||
public class MgGridDataColumn : DxGridDataColumn
|
||||
{
|
||||
private string? _urlLink;
|
||||
private bool _isInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this column should be visible in the InfoPanel. Default is true.
|
||||
/// </summary>
|
||||
|
|
@ -25,4 +30,64 @@ public class MgGridDataColumn : DxGridDataColumn
|
|||
/// </summary>
|
||||
[Parameter]
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@
|
|||
}
|
||||
else
|
||||
{
|
||||
RenderCellContent(value, displayText)(builder);
|
||||
RenderCellContent(value, displayText, column, dataItem)(builder);
|
||||
}
|
||||
builder.CloseElement();
|
||||
|
||||
|
|
@ -359,11 +359,27 @@
|
|||
builder.CloseComponent();
|
||||
}
|
||||
|
||||
private RenderFragment RenderCellContent(object? value, string displayText)
|
||||
private RenderFragment RenderCellContent(object? value, string displayText, DxGridDataColumn? column = null, object? dataItem = null)
|
||||
{
|
||||
return builder =>
|
||||
{
|
||||
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.AddAttribute(seq++, "class", "mg-info-panel-value");
|
||||
builder.AddAttribute(seq++, "title", displayText);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
@using DevExpress.Blazor
|
||||
@inject Microsoft.JSInterop.IJSRuntime JSRuntime
|
||||
|
||||
<CascadingValue Value="this">
|
||||
@if (_isFullscreen)
|
||||
|
|
@ -23,6 +24,8 @@
|
|||
private IInfoPanelBase? _infoPanelInstance;
|
||||
private IMgGridBase? _currentGrid;
|
||||
private bool _isFullscreen;
|
||||
private string _currentInfoPanelSize = "400px";
|
||||
private bool _sizeLoaded;
|
||||
|
||||
/// <summary>
|
||||
/// The grid content to display in the left pane
|
||||
|
|
@ -77,6 +80,12 @@
|
|||
public void RegisterGrid(IMgGridBase grid)
|
||||
{
|
||||
_currentGrid = grid;
|
||||
|
||||
// Load saved size when grid is registered
|
||||
if (!_sizeLoaded)
|
||||
{
|
||||
_ = LoadSavedSizeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -106,6 +115,69 @@
|
|||
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 =>
|
||||
{
|
||||
if (ShowInfoPanel)
|
||||
|
|
@ -115,7 +187,8 @@
|
|||
<DxSplitterPane>
|
||||
@GridContent
|
||||
</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)
|
||||
{
|
||||
@ChildContent
|
||||
|
|
|
|||
Loading…
Reference in New Issue