Add MgLazyLoadContent, grid layout refactor, tests

- Introduced MgLazyLoadContent component with JS observer for lazy rendering of heavy content.
- Refactored MgGridBase for user-specific, customizable layout persistence using JS interop.
- Improved MgGridInfoPanel caching and state batching for performance.
- Updated PDF viewer to use lazy loading for better UX.
- Added AyCode.Blazor.Components.Tests project with bUnit/MSTest grid layout tests.
- Updated solution/project files and removed obsolete code.
- Minor UI and JS module loading improvements.
This commit is contained in:
Loretta 2025-12-21 16:29:36 +01:00
parent 9d682bd741
commit bad4e50c17
11 changed files with 813 additions and 36 deletions

View File

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="bunit" Version="2.3.4" />
<PackageReference Include="MSTest" Version="4.0.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AyCode.Blazor.Components\AyCode.Blazor.Components.csproj" />
<ProjectReference Include="..\..\AyCode.Core\AyCode.Services.Server.Tests\AyCode.Services.Server.Tests.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,25 @@
using Bunit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AyCode.Blazor.Components.Tests;
/// <summary>
/// Base class for bUnit tests using MSTest.
/// Provides BunitContext setup and teardown.
/// </summary>
public abstract class BunitTestContext : TestContextWrapper
{
[TestInitialize]
public void Setup() => Context = new BunitContext();
[TestCleanup]
public void TearDown() => Context?.Dispose();
}
/// <summary>
/// Wrapper for bUnit BunitContext to work with MSTest lifecycle.
/// </summary>
public abstract class TestContextWrapper
{
protected BunitContext? Context { get; set; }
}

View File

@ -0,0 +1,181 @@
using AyCode.Core.Tests.TestModels;
using AyCode.Services.Server.Tests.SignalRs;
using AyCode.Services.SignalRs;
using Bunit;
using DevExpress.Blazor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AyCode.Blazor.Components.Tests.Grids;
/// <summary>
/// Tests for MgGridBase layout persistence functionality.
/// Tests that column width changes are persisted and loaded correctly.
/// </summary>
[TestClass]
public class MgGridBaseTests : BunitTestContext
{
private TestLogger _logger = null!;
private TestableSignalRHub2 _hub = null!;
private TestableSignalRClient2 _client = null!;
private TestSignalRService2 _service = null!;
private SignalRCrudTags _crudTags = null!;
private const string StorageKey = "TestGrid_Master_AutoSave_0";
[TestInitialize]
public void TestSetup()
{
Context = new BunitContext();
Context.Services.AddDevExpressBlazor();
_logger = new TestLogger();
_hub = new TestableSignalRHub2();
_service = new TestSignalRService2();
_client = new TestableSignalRClient2(_hub, _logger);
_hub.RegisterService(_service, _client);
_crudTags = new SignalRCrudTags(
TestSignalRTags.DataSourceGetAll,
TestSignalRTags.DataSourceGetItem,
TestSignalRTags.DataSourceAdd,
TestSignalRTags.DataSourceUpdate,
TestSignalRTags.DataSourceRemove
);
TestMgGridOrderItem.ClearLayoutStorage();
Context.JSInterop.Mode = JSRuntimeMode.Loose;
}
[TestCleanup]
public void TestTeardown() => Context?.Dispose();
private IRenderedComponent<TestMgGridOrderItem> RenderTestGrid(string autoSaveLayoutName = "TestGrid")
{
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)
);
}
#region Layout Persistence Tests
[TestMethod]
public async Task MgGridBase_ColumnWidth_ShouldBePersisted_WhenNewGridIsRendered()
{
// Arrange - Render first grid
var cut1 = RenderTestGrid();
// Wait for data source to load
await cut1.Instance.WaitForDataSourceLoadedAsync();
// Get columns from first grid
var columns1 = cut1.Instance.GetDataColumns();
Assert.IsTrue(columns1.Count > 0, "Grid should have columns after data source loaded");
// Set column width on first grid (must use BeginUpdate/EndUpdate on the dispatcher)
var firstColumn = columns1[0];
var expectedWidth = "150px";
await cut1.InvokeAsync(() =>
{
cut1.Instance.BeginUpdate();
firstColumn.Width = expectedWidth;
cut1.Instance.EndUpdate();
});
// Trigger layout save by disposing
await cut1.Instance.DisposeAsync();
// Act - Render second grid with same TDataItem
var cut2 = RenderTestGrid();
// Wait for data source to load
await cut2.Instance.WaitForDataSourceLoadedAsync();
// Assert - Second grid should have the same column width
var columns2 = cut2.Instance.GetDataColumns();
Assert.IsTrue(columns2.Count > 0, "Second grid should have columns");
var secondGridFirstColumn = columns2[0];
Assert.AreEqual(expectedWidth, secondGridFirstColumn.Width,
$"Column width should be persisted. Expected: {expectedWidth}, Actual: {secondGridFirstColumn.Width}");
}
[TestMethod]
public async Task MgGridBase_LayoutStorage_ShouldContainLayout_AfterGridRenders()
{
// Arrange & Act
var cut = RenderTestGrid();
await cut.Instance.WaitForDataSourceLoadedAsync();
// Assert
Assert.IsTrue(TestMgGridOrderItem.LayoutStorage.ContainsKey(StorageKey),
"Layout should be saved to storage after grid renders");
Assert.IsNotNull(TestMgGridOrderItem.LayoutStorage[StorageKey]);
}
[TestMethod]
public async Task MgGridBase_DifferentGridNames_ShouldHaveDifferentLayouts()
{
// Arrange - Render first grid with name "Grid1"
var cut1 = RenderTestGrid("Grid1");
await cut1.Instance.WaitForDataSourceLoadedAsync();
// Render second grid with name "Grid2"
var cut2 = RenderTestGrid("Grid2");
await cut2.Instance.WaitForDataSourceLoadedAsync();
// Assert - Different storage keys should exist
var storageKeys = TestMgGridOrderItem.LayoutStorage.Keys.ToList();
// There should be at least 2 different keys
Assert.IsTrue(storageKeys.Count >= 2,
$"Should have at least 2 storage keys, found: {string.Join(", ", storageKeys)}");
// Keys should contain Grid1 and Grid2
Assert.IsTrue(storageKeys.Any(k => k.Contains("Grid1")),
$"Should have Grid1 key. Keys: {string.Join(", ", storageKeys)}");
Assert.IsTrue(storageKeys.Any(k => k.Contains("Grid2")),
$"Should have Grid2 key. Keys: {string.Join(", ", storageKeys)}");
}
[TestMethod]
public async Task MgGridBase_IsMasterGrid_ShouldBeTrueWhenNoParentDataItem()
{
// Act
var cut = RenderTestGrid();
await cut.Instance.WaitForDataSourceLoadedAsync();
// Assert
Assert.IsTrue(cut.Instance.IsMasterGrid);
}
[TestMethod]
public async Task MgGridBase_Columns_ShouldBeBuiltFromReflection()
{
// Arrange & Act
var cut = RenderTestGrid();
await cut.Instance.WaitForDataSourceLoadedAsync();
// Assert - Should have columns for TestOrderItem properties
var columns = cut.Instance.GetDataColumns();
Assert.IsTrue(columns.Count > 0, "Grid should have columns built from TDataItem");
// Verify some expected column names exist
var columnNames = columns.Select(c => c.FieldName).ToList();
Assert.IsTrue(columnNames.Contains(nameof(TestOrderItem.Id)), "Should have Id column");
Assert.IsTrue(columnNames.Contains(nameof(TestOrderItem.ProductName)), "Should have ProductName column");
Assert.IsTrue(columnNames.Contains(nameof(TestOrderItem.Quantity)), "Should have Quantity column");
}
#endregion
}

View File

@ -0,0 +1,162 @@
using AyCode.Blazor.Components.Components.Grids;
using AyCode.Core.Helpers;
using AyCode.Core.Interfaces;
using AyCode.Core.Loggers;
using AyCode.Core.Tests.TestModels;
using AyCode.Services.Server.SignalRs;
using AyCode.Services.Server.Tests.SignalRs;
using AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
using AyCode.Services.SignalRs;
using DevExpress.Blazor;
using Microsoft.AspNetCore.Components;
using System.Reflection;
namespace AyCode.Blazor.Components.Tests.Grids;
/// <summary>
/// Test DataSource that extends TestOrderItemObservableDataSource with the 3-parameter constructor
/// required by MgGridBase.OnInitializedAsync which uses Activator.CreateInstance.
/// </summary>
public class TestGridOrderItemDataSource : TestOrderItemObservableDataSource
{
public TestGridOrderItemDataSource(AcSignalRClientBase signalRClient, SignalRCrudTags crudTags)
: base(signalRClient, crudTags) { }
public TestGridOrderItemDataSource(AcSignalRClientBase signalRClient, SignalRCrudTags crudTags, params object[]? contextIds)
: base(signalRClient, crudTags)
{
}
}
/// <summary>
/// Base test implementation of MgGridBase for testing grid functionality.
/// Overrides layout persistence to use in-memory storage for testing.
/// Automatically builds columns from TDataItem properties using reflection.
/// </summary>
public abstract class TestMgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient>
: MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient>
where TSignalRDataSource : AcSignalRDataSource<TDataItem, TId, AcObservableCollection<TDataItem>>
where TDataItem : class, IId<TId>
where TId : struct
where TLoggerClient : AcLoggerBase
{
private int _testUserId;
private bool _columnsInitialized;
/// <summary>
/// In-memory storage for layout persistence testing.
/// Shared across all instances to simulate localStorage behavior.
/// </summary>
public static Dictionary<string, GridPersistentLayout?> LayoutStorage { get; } = new();
/// <summary>
/// Indicates whether data source has been loaded
/// </summary>
public bool IsDataSourceLoaded { get; private set; }
public void SetTestUserId(int userId) => _testUserId = userId;
public int GetTestUserId() => _testUserId;
protected override int GetLayoutUserId() => _testUserId;
public static void ClearLayoutStorage() => LayoutStorage.Clear();
protected override Task<GridPersistentLayout?> LoadLayoutFromLocalStorageAsync(string localStorageKey)
{
LayoutStorage.TryGetValue(localStorageKey, out var layout);
return Task.FromResult(layout);
}
protected override Task SaveLayoutToLocalStorageAsync(GridPersistentLayout layout, string localStorageKey)
{
LayoutStorage[localStorageKey] = layout;
return Task.CompletedTask;
}
/// <summary>
/// Waits for the data source to be loaded using TaskHelper
/// </summary>
public Task<bool> WaitForDataSourceLoadedAsync(int timeoutMs = 5000)
{
return TaskHelper.WaitToAsync(() => IsDataSourceLoaded, timeoutMs);
}
protected override void OnParametersSet()
{
if (!_columnsInitialized)
{
// Build columns from TDataItem properties using reflection
Columns = BuildColumnsFromDataItem;
_columnsInitialized = true;
}
base.OnParametersSet();
// Subscribe to OnDataSourceChanged to know when data is loaded
OnDataSourceChanged = EventCallback.Factory.Create<IList<TDataItem>>(this, _ =>
{
IsDataSourceLoaded = true;
});
}
/// <summary>
/// Builds grid columns from TDataItem properties using reflection
/// </summary>
private void BuildColumnsFromDataItem(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder)
{
var properties = typeof(TDataItem).GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && IsSimpleType(p.PropertyType))
.ToList();
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));
builder.CloseComponent();
}
}
/// <summary>
/// Determines if a type is a simple type suitable for grid display
/// </summary>
private static bool IsSimpleType(Type type)
{
var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
return underlyingType.IsPrimitive
|| underlyingType == typeof(string)
|| underlyingType == typeof(decimal)
|| underlyingType == typeof(DateTime)
|| underlyingType == typeof(DateTimeOffset)
|| underlyingType == typeof(TimeSpan)
|| underlyingType == typeof(Guid)
|| underlyingType.IsEnum;
}
/// <summary>
/// Gets default column width based on property type
/// </summary>
private static string GetDefaultWidth(Type type)
{
var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
return underlyingType switch
{
_ when underlyingType == typeof(int) || underlyingType == typeof(long) => "80px",
_ when underlyingType == typeof(decimal) || underlyingType == typeof(double) || underlyingType == typeof(float) => "100px",
_ when underlyingType == typeof(bool) => "60px",
_ when underlyingType == typeof(DateTime) || underlyingType == typeof(DateTimeOffset) => "150px",
_ when underlyingType == typeof(string) => "200px",
_ when underlyingType == typeof(Guid) => "250px",
_ => "120px"
};
}
}
/// <summary>
/// Test grid for TestOrderItem entities
/// </summary>
public class TestMgGridOrderItem : TestMgGridBase<TestGridOrderItemDataSource, TestOrderItem, int, TestLogger>
{
}

View File

@ -0,0 +1 @@
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]

View File

@ -9,8 +9,10 @@ using AyCode.Utils.Extensions;
using DevExpress.Blazor;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.JSInterop;
using System.ComponentModel;
using System.Reflection;
using System.Text.Json;
using DevExpress.Blazor.Internal;
namespace AyCode.Blazor.Components.Components.Grids;
@ -214,6 +216,8 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
protected bool HasIdValue(TId id) => !_equalityComparerId.Equals(id, default);
protected bool IdEquals(TId id1, TId id2) => _equalityComparerId.Equals(id1, id2);
[Inject] protected IJSRuntime JSRuntime { get; set; } = null!;
[Parameter] public TLoggerClient Logger { get; set; }
[Parameter] public string GridName { get; set; }
[Parameter] public IId<TId>? ParentDataItem { get; set; }
@ -221,6 +225,11 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
[Parameter] public object[]? ContextIds { get; set; }
[Parameter] public string Caption { get; set; } = typeof(TDataItem).Name;
/// <summary>
/// Name for auto-saving/loading grid layout. If not set, defaults to "Grid{TDataItem.Name}"
/// </summary>
[Parameter] public string? AutoSaveLayoutName { get; set; }
public bool IsMasterGrid => ParentDataItem == null;
protected PropertyInfo? KeyFieldPropertyInfoToParent;
@ -466,7 +475,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
await OnGridCustomizeEditModel.InvokeAsync(e);
// Update InfoPanel to edit mode
InfoPanelInstance?.SetEditMode(editModel);
InfoPanelInstance?.SetEditMode(this, editModel);
await InvokeAsync(StateHasChanged);
}
@ -664,6 +673,14 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
{
if (GridName.IsNullOrWhiteSpace()) GridName = $"{typeof(TDataItem).Name}Grid";
_gridLogName = $"[{GridName}]";
// Set default AutoSaveLayoutName if not provided
if (AutoSaveLayoutName.IsNullOrWhiteSpace())
AutoSaveLayoutName = $"Grid{typeof(TDataItem).Name}";
// Set up layout auto-loading/saving
LayoutAutoLoading = Grid_LayoutAutoLoading;
LayoutAutoSaving = Grid_LayoutAutoSaving;
IsFirstInitializeParameters = true;
}
@ -671,6 +688,59 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
base.OnParametersSet();
}
#region Layout Persistence
/// <summary>
/// Gets the user-specific layout storage key. Override to provide custom user identification.
/// </summary>
protected virtual int GetLayoutUserId() => 0;
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);
}
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);
}
protected virtual async Task<GridPersistentLayout?> LoadLayoutFromLocalStorageAsync(string localStorageKey)
{
try
{
var json = await JSRuntime.InvokeAsync<string>("localStorage.getItem", localStorageKey);
if (!string.IsNullOrWhiteSpace(json))
return JsonSerializer.Deserialize<GridPersistentLayout>(json);
}
catch
{
// Mute exceptions for the server prerender stage
}
return null;
}
protected virtual async Task SaveLayoutToLocalStorageAsync(GridPersistentLayout layout, string localStorageKey)
{
try
{
var json = JsonSerializer.Serialize(layout);
await JSRuntime.InvokeVoidAsync("localStorage.setItem", localStorageKey, json);
}
catch
{
// Mute exceptions for the server prerender stage
}
}
#endregion
//public Task AddDataItem(TDataItem dataItem, int messageTag) => PostDataToServerAsync(dataItem, messageTag, TrackingState.Add);
public Task UpdateDataItem(TDataItem dataItem) => _dataSource.Update(dataItem, true);

View File

@ -143,7 +143,7 @@
var underlyingType = Nullable.GetUnderlyingType(propertyInfo.PropertyType) ?? propertyInfo.PropertyType;
if (settingsType == EditSettingsType.ComboBox && GetEditSettings(column.FieldName) is DxComboBoxSettings comboSettings)
if (settingsType == EditSettingsType.ComboBox && GetEditSettingsCached(dataItemType, column.FieldName) is DxComboBoxSettings comboSettings)
{
RenderComboBoxEditor(builder, ref seq, dataItem, propertyInfo, comboSettings);
return;

View File

@ -10,7 +10,7 @@ namespace AyCode.Blazor.Components.Components.Grids;
public interface IInfoPanelBase
{
void ClearEditMode();
void SetEditMode(object editModel);
void SetEditMode(IMgGridBase grid, object editModel);
void RefreshData(IMgGridBase grid, object? dataItem, int visibleIndex = -1);
}
@ -59,7 +59,7 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
private ElementReference _panelElement;
private bool _isJsInitialized;
private const int DefaultTopOffset = 300; // Increased from 180 to account for header + tabs + toolbar
private const int DefaultTopOffset = 300;
protected IMgGridBase? _currentGrid;
protected object? _currentDataItem;
@ -70,16 +70,19 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
protected bool _isEditMode;
protected object? _editModel;
// Cache for edit settings to avoid repeated lookups
private readonly Dictionary<string, IEditSettings?> _editSettingsCache = [];
// Type-based caches for performance optimization
private readonly Dictionary<(Type, string), IEditSettings?> _editSettingsCache = [];
private readonly Dictionary<Type, List<DxGridDataColumn>> _columnsCache = [];
private readonly Dictionary<(Type, object, object), string?> _comboBoxTextCache = [];
// Track if we need to update UI
private bool _pendingStateChange;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Register this InfoPanel with the wrapper
GridWrapper?.RegisterInfoPanel(this);
await InitializeStickyAsync();
}
}
@ -110,42 +113,61 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
_currentGrid = grid;
_currentDataItem = dataItem;
_focusedRowVisibleIndex = visibleIndex;
_editSettingsCache.Clear();
// Clear edit mode when refreshing with new data
_isEditMode = false;
_editModel = null;
// Use cached columns if available
if (_currentGrid != null && _currentDataItem != null)
{
_allDataColumns = GetAllDataColumns(_currentGrid);
var dataItemType = _currentDataItem.GetType();
_allDataColumns = GetAllDataColumnsCached(dataItemType, _currentGrid);
}
else
{
_allDataColumns = [];
}
StateHasChanged();
// Notify subscribers that data item changed
_ = OnDataItemChanged.InvokeAsync(dataItem);
// Batch state changes
if (!_pendingStateChange)
{
_pendingStateChange = true;
InvokeAsync(async () =>
{
_pendingStateChange = false;
StateHasChanged();
await OnDataItemChanged.InvokeAsync(dataItem);
});
}
}
/// <summary>
/// Sets the InfoPanel to edit mode with the given edit model
/// </summary>
public void SetEditMode(object editModel)
public void SetEditMode(IMgGridBase grid, object editModel)
{
ArgumentNullException.ThrowIfNull(grid);
ArgumentNullException.ThrowIfNull(editModel);
_currentGrid = grid;
_editModel = editModel;
_isEditMode = true;
_currentDataItem = _editModel;
if (_currentGrid != null)
{
_allDataColumns = GetAllDataColumns(_currentGrid);
}
var dataItemType = _editModel.GetType();
_allDataColumns = GetAllDataColumnsCached(dataItemType, _currentGrid);
InvokeAsync(StateHasChanged);
// Batch state changes
if (!_pendingStateChange)
{
_pendingStateChange = true;
InvokeAsync(() =>
{
_pendingStateChange = false;
StateHasChanged();
});
}
}
/// <summary>
@ -155,8 +177,16 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
{
_isEditMode = false;
_editModel = null;
_editSettingsCache.Clear();
InvokeAsync(StateHasChanged);
if (!_pendingStateChange)
{
_pendingStateChange = true;
InvokeAsync(() =>
{
_pendingStateChange = false;
StateHasChanged();
});
}
}
/// <summary>
@ -168,7 +198,6 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
_currentDataItem = null;
_focusedRowVisibleIndex = -1;
_allDataColumns = [];
_editSettingsCache.Clear();
_isEditMode = false;
_editModel = null;
StateHasChanged();
@ -187,6 +216,11 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
// Ignore disposal errors
}
}
// Clear all caches on dispose
_editSettingsCache.Clear();
_columnsCache.Clear();
_comboBoxTextCache.Clear();
}
/// <summary>
@ -211,11 +245,13 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
if (value == null)
return string.Empty;
var dataItemType = dataItem.GetType();
// Try to resolve display text from EditSettings
var editSettings = GetEditSettings(column.FieldName);
var editSettings = GetEditSettingsCached(dataItemType, column.FieldName);
if (editSettings is DxComboBoxSettings comboSettings)
{
var displayText = ResolveComboBoxDisplayText(comboSettings, value);
var displayText = ResolveComboBoxDisplayTextCached(dataItemType, comboSettings, value);
if (!string.IsNullOrEmpty(displayText))
return displayText;
}
@ -242,14 +278,16 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
}
/// <summary>
/// Gets edit settings for the specified field (with caching)
/// Gets edit settings for the specified field with Type-based caching
/// </summary>
private IEditSettings? GetEditSettings(string fieldName)
private IEditSettings? GetEditSettingsCached(Type dataItemType, string fieldName)
{
if (_currentGrid == null || string.IsNullOrEmpty(fieldName))
return null;
if (_editSettingsCache.TryGetValue(fieldName, out var cached))
var cacheKey = (dataItemType, fieldName);
if (_editSettingsCache.TryGetValue(cacheKey, out var cached))
return cached;
IEditSettings? settings = null;
@ -269,10 +307,27 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
// Ignore errors
}
_editSettingsCache[fieldName] = settings;
_editSettingsCache[cacheKey] = settings;
return settings;
}
/// <summary>
/// Cached version of ResolveComboBoxDisplayText with Type-based key
/// </summary>
private string? ResolveComboBoxDisplayTextCached(Type dataItemType, DxComboBoxSettings settings, object value)
{
// Use settings object reference and value as cache key
var cacheKey = (dataItemType, (object)settings, value);
if (_comboBoxTextCache.TryGetValue(cacheKey, out var cached))
return cached;
var result = ResolveComboBoxDisplayText(settings, value);
_comboBoxTextCache[cacheKey] = result;
return result;
}
private string? ResolveComboBoxDisplayText(DxComboBoxSettings settings, object value)
{
if (settings.Data == null || string.IsNullOrEmpty(settings.ValueFieldName) || string.IsNullOrEmpty(settings.TextFieldName))
@ -355,6 +410,20 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
}
}
/// <summary>
/// Cached version of GetAllDataColumns with Type-based key
/// </summary>
private List<DxGridDataColumn> GetAllDataColumnsCached(Type dataItemType, IMgGridBase grid)
{
if (_columnsCache.TryGetValue(dataItemType, out var cached))
return cached;
var columns = GetAllDataColumns(grid);
_columnsCache[dataItemType] = columns;
return columns;
}
protected static List<DxGridDataColumn> GetAllDataColumns(IMgGridBase grid)
{
var columns = new List<DxGridDataColumn>();
@ -388,7 +457,11 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
/// </summary>
private EditSettingsType GetEditSettingsType(DxGridDataColumn column)
{
var settings = GetEditSettings(column.FieldName);
var dataItem = GetActiveDataItem();
if (dataItem == null) return EditSettingsType.None;
var dataItemType = dataItem.GetType();
var settings = GetEditSettingsCached(dataItemType, column.FieldName);
return settings switch
{

View File

@ -0,0 +1,183 @@
@using Microsoft.JSInterop
@inject IJSRuntime JS
<div @ref="_containerRef" class="@ContainerCssClass" style="@ContainerStyle">
@if (IsVisible || ForceRender)
{
@ChildContent
}
else if (PlaceholderContent != null)
{
@PlaceholderContent
}
else
{
<div class="lazy-content-placeholder" style="min-height: @MinHeight;">
@if (ShowLoadingIndicator)
{
<div class="text-center py-3">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Betöltés...</span>
</div>
</div>
}
</div>
}
</div>
@code {
private ElementReference _containerRef;
private DotNetObjectReference<MgLazyLoadContent>? _dotNetRef;
private bool _isObserverInitialized;
/// <summary>
/// Content to render when visible
/// </summary>
[Parameter, EditorRequired]
public RenderFragment? ChildContent { get; set; }
/// <summary>
/// Optional placeholder content to show before the element becomes visible
/// </summary>
[Parameter]
public RenderFragment? PlaceholderContent { get; set; }
/// <summary>
/// Root margin for IntersectionObserver (e.g., "100px" to load 100px before visible)
/// </summary>
[Parameter]
public string RootMargin { get; set; } = "50px";
/// <summary>
/// Threshold for IntersectionObserver (0.0 to 1.0)
/// </summary>
[Parameter]
public double Threshold { get; set; } = 0.01;
/// <summary>
/// Minimum height for the placeholder (prevents layout shift)
/// </summary>
[Parameter]
public string MinHeight { get; set; } = "100px";
/// <summary>
/// CSS class for the container
/// </summary>
[Parameter]
public string? ContainerCssClass { get; set; }
/// <summary>
/// Inline style for the container
/// </summary>
[Parameter]
public string? ContainerStyle { get; set; }
/// <summary>
/// Force render regardless of visibility (useful for disabling lazy loading)
/// </summary>
[Parameter]
public bool ForceRender { get; set; }
/// <summary>
/// Show a loading spinner in the placeholder
/// </summary>
[Parameter]
public bool ShowLoadingIndicator { get; set; } = true;
/// <summary>
/// Callback when content becomes visible
/// </summary>
[Parameter]
public EventCallback OnContentVisible { get; set; }
/// <summary>
/// Gets whether the content is currently visible
/// </summary>
public bool IsVisible { get; private set; }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !ForceRender)
{
await InitializeObserverAsync();
}
}
private async Task InitializeObserverAsync()
{
if (_isObserverInitialized) return;
try
{
_dotNetRef = DotNetObjectReference.Create(this);
// Initialize observer and check immediate visibility
var isCurrentlyVisible = await JS.InvokeAsync<bool>(
"lazyContentObserver.observe",
_containerRef,
_dotNetRef,
RootMargin,
Threshold);
_isObserverInitialized = true;
// If already visible, trigger the callback immediately
if (isCurrentlyVisible && !IsVisible)
{
await OnVisibilityChanged(true);
}
}
catch (JSException ex)
{
Console.WriteLine($"MgLazyLoadContent: Failed to initialize observer: {ex.Message}");
// Fallback: render immediately if JS fails
IsVisible = true;
await OnContentVisible.InvokeAsync();
StateHasChanged();
}
}
[JSInvokable]
public async Task OnVisibilityChanged(bool isVisible)
{
if (IsVisible == isVisible) return;
IsVisible = isVisible;
if (IsVisible)
{
await OnContentVisible.InvokeAsync();
}
StateHasChanged();
}
/// <summary>
/// Manually triggers the OnContentVisible callback if the content is currently visible.
/// Useful when the content data changes but visibility hasn't changed.
/// </summary>
public async Task TriggerContentVisibleAsync()
{
if (IsVisible)
{
await OnContentVisible.InvokeAsync();
}
}
public async ValueTask DisposeAsync()
{
if (_isObserverInitialized)
{
try
{
await JS.InvokeVoidAsync("lazyContentObserver.unobserve", _containerRef);
}
catch
{
// Ignore errors during disposal
}
}
_dotNetRef?.Dispose();
}
}

View File

@ -1,6 +0,0 @@
// This is a JavaScript module that is loaded on demand. It can export any number of
// functions, and may import other JavaScript modules if required.
export function showPrompt(message) {
return prompt(message, 'Type anything here');
}

View File

@ -0,0 +1,64 @@
// Lazy content observer - renders content only when visible in viewport
window.lazyContentObserver = {
_observers: new Map(),
observe: function (element, dotNetRef, rootMargin, threshold) {
if (!element) {
console.error('[MgLazyLoadContent] Element not found');
return false;
}
// Clean up existing observer for this element
this.unobserve(element);
const options = {
root: null, // viewport
rootMargin: rootMargin || '50px',
threshold: threshold || 0.01
};
// Check immediate visibility before setting up observer
const rect = element.getBoundingClientRect();
const isCurrentlyVisible = (
rect.top < window.innerHeight &&
rect.bottom > 0 &&
rect.left < window.innerWidth &&
rect.right > 0
);
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// Invoke .NET callback
dotNetRef.invokeMethodAsync('OnVisibilityChanged', entry.isIntersecting)
.catch(err => console.error('[MgLazyLoadContent] Callback error:', err));
});
}, options);
observer.observe(element);
this._observers.set(element, { observer, dotNetRef });
console.log('[MgLazyLoadContent] Observer initialized. Currently visible:', isCurrentlyVisible);
// Return immediate visibility status
return isCurrentlyVisible;
},
unobserve: function (element) {
if (!element) return;
const data = this._observers.get(element);
if (data) {
data.observer.disconnect();
this._observers.delete(element);
console.log('[MgLazyLoadContent] Observer removed');
}
},
dispose: function () {
this._observers.forEach((data, element) => {
data.observer.disconnect();
});
this._observers.clear();
console.log('[MgLazyLoadContent] All observers disposed');
}
};