diff --git a/AyCode.Blazor.Components.Tests/Grids/MgGridBaseTests.cs b/AyCode.Blazor.Components.Tests/Grids/MgGridBaseTests.cs index 92a33bb..904116a 100644 --- a/AyCode.Blazor.Components.Tests/Grids/MgGridBaseTests.cs +++ b/AyCode.Blazor.Components.Tests/Grids/MgGridBaseTests.cs @@ -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 RenderTestGrid(string autoSaveLayoutName = "TestGrid") + private IRenderedComponent RenderTestGrid( + string autoSaveLayoutName = "TestGrid", + System.Action? onDynamicColumnAttributeAdding = null) { var dataSource = new TestGridOrderItemDataSource(_client, _crudTags); - return Context!.Render(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(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 } diff --git a/AyCode.Blazor.Components.Tests/Grids/TestMgGrid.cs b/AyCode.Blazor.Components.Tests/Grids/TestMgGrid.cs index 0b32965..5e29a31 100644 --- a/AyCode.Blazor.Components.Tests/Grids/TestMgGrid.cs +++ b/AyCode.Blazor.Components.Tests/Grids/TestMgGrid.cs @@ -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 } } +/// +/// Event args for dynamic column adding event. +/// Provides a delegate to add custom attributes to the column. +/// +public class DynamicColumnAddingEventArgs +{ + public required string FieldName { get; init; } + public required PropertyInfo PropertyInfo { get; init; } + + /// + /// Dictionary of additional attributes to add to the column. + /// Key is the attribute name, value is the attribute value. + /// + public Dictionary AdditionalAttributes { get; } = new(); +} + /// /// 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 public bool IsDataSourceLoaded { get; private set; } + /// + /// Event called when a dynamic column is being added. Allows customization of column properties. + /// Add attributes to eventArgs.AdditionalAttributes dictionary. + /// + [Parameter] + public Action? 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 /// Builds grid columns from TDataItem properties using reflection /// - 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(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(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(); } } diff --git a/AyCode.Blazor.Components/Components/Grids/MgGridBase.cs b/AyCode.Blazor.Components/Components/Grids/MgGridBase.cs index 1553c38..f9efa1a 100644 --- a/AyCode.Blazor.Components/Components/Grids/MgGridBase.cs +++ b/AyCode.Blazor.Components/Components/Grids/MgGridBase.cs @@ -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 /// bool IsFullscreen { get; } + string LayoutStorageKey { get; } /// /// Toggles fullscreen mode for the grid (or wrapper if available) @@ -105,6 +107,15 @@ public abstract class MgGridBase /// Reference to the wrapper component for grid-InfoPanel communication /// @@ -682,6 +693,9 @@ public abstract class MgGridBase LoadLayoutFromLocalStorageAsync(string localStorageKey) diff --git a/AyCode.Blazor.Components/Components/Grids/MgGridDataColumn.cs b/AyCode.Blazor.Components/Components/Grids/MgGridDataColumn.cs index 5f0541f..a0083c2 100644 --- a/AyCode.Blazor.Components/Components/Grids/MgGridDataColumn.cs +++ b/AyCode.Blazor.Components/Components/Grids/MgGridDataColumn.cs @@ -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; /// public class MgGridDataColumn : DxGridDataColumn { + private string? _urlLink; + private bool _isInitialized; + /// /// Whether this column should be visible in the InfoPanel. Default is true. /// @@ -25,4 +30,64 @@ public class MgGridDataColumn : DxGridDataColumn /// [Parameter] public int InfoPanelOrder { get; set; } = int.MaxValue; + + /// + /// URL template with {property} placeholders that will be replaced with row values. + /// Example: https://shop.fruitbank.hu/Admin/Order/Edit/{Id}/{OrderId} + /// + [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(); + }; + } + } + + /// + /// Replaces {property} placeholders in the template with values from the data item. + /// Exposed for unit testing. + /// + 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; + }); + } } diff --git a/AyCode.Blazor.Components/Components/Grids/MgGridInfoPanel.razor b/AyCode.Blazor.Components/Components/Grids/MgGridInfoPanel.razor index e3b9b04..5ed8495 100644 --- a/AyCode.Blazor.Components/Components/Grids/MgGridInfoPanel.razor +++ b/AyCode.Blazor.Components/Components/Grids/MgGridInfoPanel.razor @@ -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); diff --git a/AyCode.Blazor.Components/Components/Grids/MgGridWithInfoPanel.razor b/AyCode.Blazor.Components/Components/Grids/MgGridWithInfoPanel.razor index b1f7afa..d4bff51 100644 --- a/AyCode.Blazor.Components/Components/Grids/MgGridWithInfoPanel.razor +++ b/AyCode.Blazor.Components/Components/Grids/MgGridWithInfoPanel.razor @@ -1,4 +1,5 @@ @using DevExpress.Blazor +@inject Microsoft.JSInterop.IJSRuntime JSRuntime @if (_isFullscreen) @@ -23,6 +24,8 @@ private IInfoPanelBase? _infoPanelInstance; private IMgGridBase? _currentGrid; private bool _isFullscreen; + private string _currentInfoPanelSize = "400px"; + private bool _sizeLoaded; /// /// 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(); + } } /// @@ -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("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 @@ @GridContent - + @if (ChildContent != null) { @ChildContent