From bad4e50c17c9780e7639c534452ecbbaf97a8f13 Mon Sep 17 00:00:00 2001 From: Loretta Date: Sun, 21 Dec 2025 16:29:36 +0100 Subject: [PATCH] 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. --- .../AyCode.Blazor.Components.Tests.csproj | 24 +++ .../BunitTestContext.cs | 25 +++ .../Grids/MgGridBaseTests.cs | 181 +++++++++++++++++ .../Grids/TestMgGrid.cs | 162 ++++++++++++++++ .../MSTestSettings.cs | 1 + .../Components/Grids/MgGridBase.cs | 72 ++++++- .../Components/Grids/MgGridInfoPanel.razor | 2 +- .../Components/Grids/MgGridInfoPanel.razor.cs | 129 +++++++++--- .../Components/MgLazyLoadContent.razor | 183 ++++++++++++++++++ .../wwwroot/exampleJsInterop.js | 6 - .../wwwroot/js/mgLazyLoadContentObserver.js | 64 ++++++ 11 files changed, 813 insertions(+), 36 deletions(-) create mode 100644 AyCode.Blazor.Components.Tests/AyCode.Blazor.Components.Tests.csproj create mode 100644 AyCode.Blazor.Components.Tests/BunitTestContext.cs create mode 100644 AyCode.Blazor.Components.Tests/Grids/MgGridBaseTests.cs create mode 100644 AyCode.Blazor.Components.Tests/Grids/TestMgGrid.cs create mode 100644 AyCode.Blazor.Components.Tests/MSTestSettings.cs create mode 100644 AyCode.Blazor.Components/Components/MgLazyLoadContent.razor delete mode 100644 AyCode.Blazor.Components/wwwroot/exampleJsInterop.js create mode 100644 AyCode.Blazor.Components/wwwroot/js/mgLazyLoadContentObserver.js diff --git a/AyCode.Blazor.Components.Tests/AyCode.Blazor.Components.Tests.csproj b/AyCode.Blazor.Components.Tests/AyCode.Blazor.Components.Tests.csproj new file mode 100644 index 0000000..7d73d4c --- /dev/null +++ b/AyCode.Blazor.Components.Tests/AyCode.Blazor.Components.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + latest + enable + enable + + + + + + + + + + + + + + + + + diff --git a/AyCode.Blazor.Components.Tests/BunitTestContext.cs b/AyCode.Blazor.Components.Tests/BunitTestContext.cs new file mode 100644 index 0000000..67350a7 --- /dev/null +++ b/AyCode.Blazor.Components.Tests/BunitTestContext.cs @@ -0,0 +1,25 @@ +using Bunit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AyCode.Blazor.Components.Tests; + +/// +/// Base class for bUnit tests using MSTest. +/// Provides BunitContext setup and teardown. +/// +public abstract class BunitTestContext : TestContextWrapper +{ + [TestInitialize] + public void Setup() => Context = new BunitContext(); + + [TestCleanup] + public void TearDown() => Context?.Dispose(); +} + +/// +/// Wrapper for bUnit BunitContext to work with MSTest lifecycle. +/// +public abstract class TestContextWrapper +{ + protected BunitContext? Context { get; set; } +} diff --git a/AyCode.Blazor.Components.Tests/Grids/MgGridBaseTests.cs b/AyCode.Blazor.Components.Tests/Grids/MgGridBaseTests.cs new file mode 100644 index 0000000..92a33bb --- /dev/null +++ b/AyCode.Blazor.Components.Tests/Grids/MgGridBaseTests.cs @@ -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; + +/// +/// Tests for MgGridBase layout persistence functionality. +/// Tests that column width changes are persisted and loaded correctly. +/// +[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 RenderTestGrid(string autoSaveLayoutName = "TestGrid") + { + 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) + ); + } + + #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 +} diff --git a/AyCode.Blazor.Components.Tests/Grids/TestMgGrid.cs b/AyCode.Blazor.Components.Tests/Grids/TestMgGrid.cs new file mode 100644 index 0000000..0b32965 --- /dev/null +++ b/AyCode.Blazor.Components.Tests/Grids/TestMgGrid.cs @@ -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; + +/// +/// Test DataSource that extends TestOrderItemObservableDataSource with the 3-parameter constructor +/// required by MgGridBase.OnInitializedAsync which uses Activator.CreateInstance. +/// +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) + { + } +} + +/// +/// 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. +/// +public abstract class TestMgGridBase + : MgGridBase + where TSignalRDataSource : AcSignalRDataSource> + where TDataItem : class, IId + where TId : struct + where TLoggerClient : AcLoggerBase +{ + private int _testUserId; + private bool _columnsInitialized; + + /// + /// In-memory storage for layout persistence testing. + /// Shared across all instances to simulate localStorage behavior. + /// + public static Dictionary LayoutStorage { get; } = new(); + + /// + /// Indicates whether data source has been loaded + /// + 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 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; + } + + /// + /// Waits for the data source to be loaded using TaskHelper + /// + public Task 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>(this, _ => + { + IsDataSourceLoaded = true; + }); + } + + /// + /// Builds grid columns from TDataItem properties using reflection + /// + 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(seq++); + builder.AddAttribute(seq++, nameof(DxGridDataColumn.FieldName), property.Name); + builder.AddAttribute(seq++, nameof(DxGridDataColumn.Width), GetDefaultWidth(property.PropertyType)); + builder.CloseComponent(); + } + } + + /// + /// Determines if a type is a simple type suitable for grid display + /// + 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; + } + + /// + /// Gets default column width based on property type + /// + 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" + }; + } +} + +/// +/// Test grid for TestOrderItem entities +/// +public class TestMgGridOrderItem : TestMgGridBase +{ +} diff --git a/AyCode.Blazor.Components.Tests/MSTestSettings.cs b/AyCode.Blazor.Components.Tests/MSTestSettings.cs new file mode 100644 index 0000000..aaf278c --- /dev/null +++ b/AyCode.Blazor.Components.Tests/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/AyCode.Blazor.Components/Components/Grids/MgGridBase.cs b/AyCode.Blazor.Components/Components/Grids/MgGridBase.cs index fd290bf..1553c38 100644 --- a/AyCode.Blazor.Components/Components/Grids/MgGridBase.cs +++ b/AyCode.Blazor.Components/Components/Grids/MgGridBase.cs @@ -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 !_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? ParentDataItem { get; set; } @@ -221,6 +225,11 @@ public abstract class MgGridBase + /// Name for auto-saving/loading grid layout. If not set, defaults to "Grid{TDataItem.Name}" + /// + [Parameter] public string? AutoSaveLayoutName { get; set; } public bool IsMasterGrid => ParentDataItem == null; protected PropertyInfo? KeyFieldPropertyInfoToParent; @@ -466,7 +475,7 @@ public abstract class MgGridBase + /// Gets the user-specific layout storage key. Override to provide custom user identification. + /// + 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 LoadLayoutFromLocalStorageAsync(string localStorageKey) + { + try + { + var json = await JSRuntime.InvokeAsync("localStorage.getItem", localStorageKey); + + if (!string.IsNullOrWhiteSpace(json)) + return JsonSerializer.Deserialize(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); diff --git a/AyCode.Blazor.Components/Components/Grids/MgGridInfoPanel.razor b/AyCode.Blazor.Components/Components/Grids/MgGridInfoPanel.razor index 243999e..e3b9b04 100644 --- a/AyCode.Blazor.Components/Components/Grids/MgGridInfoPanel.razor +++ b/AyCode.Blazor.Components/Components/Grids/MgGridInfoPanel.razor @@ -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; diff --git a/AyCode.Blazor.Components/Components/Grids/MgGridInfoPanel.razor.cs b/AyCode.Blazor.Components/Components/Grids/MgGridInfoPanel.razor.cs index 4f497ba..731ca78 100644 --- a/AyCode.Blazor.Components/Components/Grids/MgGridInfoPanel.razor.cs +++ b/AyCode.Blazor.Components/Components/Grids/MgGridInfoPanel.razor.cs @@ -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 _editSettingsCache = []; + // Type-based caches for performance optimization + private readonly Dictionary<(Type, string), IEditSettings?> _editSettingsCache = []; + private readonly Dictionary> _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); + }); + } } /// /// Sets the InfoPanel to edit mode with the given edit model /// - 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(); + }); + } } /// @@ -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(); + }); + } } /// @@ -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(); } /// @@ -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 } /// - /// Gets edit settings for the specified field (with caching) + /// Gets edit settings for the specified field with Type-based caching /// - 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; } + /// + /// Cached version of ResolveComboBoxDisplayText with Type-based key + /// + 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 } } + /// + /// Cached version of GetAllDataColumns with Type-based key + /// + private List 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 GetAllDataColumns(IMgGridBase grid) { var columns = new List(); @@ -388,7 +457,11 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan /// 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 { diff --git a/AyCode.Blazor.Components/Components/MgLazyLoadContent.razor b/AyCode.Blazor.Components/Components/MgLazyLoadContent.razor new file mode 100644 index 0000000..cd0d3ec --- /dev/null +++ b/AyCode.Blazor.Components/Components/MgLazyLoadContent.razor @@ -0,0 +1,183 @@ +@using Microsoft.JSInterop +@inject IJSRuntime JS + +
+ @if (IsVisible || ForceRender) + { + @ChildContent + } + else if (PlaceholderContent != null) + { + @PlaceholderContent + } + else + { +
+ @if (ShowLoadingIndicator) + { +
+
+ Betöltés... +
+
+ } +
+ } +
+ +@code { + private ElementReference _containerRef; + private DotNetObjectReference? _dotNetRef; + private bool _isObserverInitialized; + + /// + /// Content to render when visible + /// + [Parameter, EditorRequired] + public RenderFragment? ChildContent { get; set; } + + /// + /// Optional placeholder content to show before the element becomes visible + /// + [Parameter] + public RenderFragment? PlaceholderContent { get; set; } + + /// + /// Root margin for IntersectionObserver (e.g., "100px" to load 100px before visible) + /// + [Parameter] + public string RootMargin { get; set; } = "50px"; + + /// + /// Threshold for IntersectionObserver (0.0 to 1.0) + /// + [Parameter] + public double Threshold { get; set; } = 0.01; + + /// + /// Minimum height for the placeholder (prevents layout shift) + /// + [Parameter] + public string MinHeight { get; set; } = "100px"; + + /// + /// CSS class for the container + /// + [Parameter] + public string? ContainerCssClass { get; set; } + + /// + /// Inline style for the container + /// + [Parameter] + public string? ContainerStyle { get; set; } + + /// + /// Force render regardless of visibility (useful for disabling lazy loading) + /// + [Parameter] + public bool ForceRender { get; set; } + + /// + /// Show a loading spinner in the placeholder + /// + [Parameter] + public bool ShowLoadingIndicator { get; set; } = true; + + /// + /// Callback when content becomes visible + /// + [Parameter] + public EventCallback OnContentVisible { get; set; } + + /// + /// Gets whether the content is currently visible + /// + 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( + "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(); + } + + /// + /// Manually triggers the OnContentVisible callback if the content is currently visible. + /// Useful when the content data changes but visibility hasn't changed. + /// + 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(); + } +} diff --git a/AyCode.Blazor.Components/wwwroot/exampleJsInterop.js b/AyCode.Blazor.Components/wwwroot/exampleJsInterop.js deleted file mode 100644 index ea8d76a..0000000 --- a/AyCode.Blazor.Components/wwwroot/exampleJsInterop.js +++ /dev/null @@ -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'); -} diff --git a/AyCode.Blazor.Components/wwwroot/js/mgLazyLoadContentObserver.js b/AyCode.Blazor.Components/wwwroot/js/mgLazyLoadContentObserver.js new file mode 100644 index 0000000..31f2831 --- /dev/null +++ b/AyCode.Blazor.Components/wwwroot/js/mgLazyLoadContentObserver.js @@ -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'); + } +};