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:
parent
9d682bd741
commit
bad4e50c17
|
|
@ -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>
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
|
||||||
|
|
@ -9,8 +9,10 @@ using AyCode.Utils.Extensions;
|
||||||
using DevExpress.Blazor;
|
using DevExpress.Blazor;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Rendering;
|
using Microsoft.AspNetCore.Components.Rendering;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
using DevExpress.Blazor.Internal;
|
using DevExpress.Blazor.Internal;
|
||||||
|
|
||||||
namespace AyCode.Blazor.Components.Components.Grids;
|
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 HasIdValue(TId id) => !_equalityComparerId.Equals(id, default);
|
||||||
protected bool IdEquals(TId id1, TId id2) => _equalityComparerId.Equals(id1, id2);
|
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 TLoggerClient Logger { get; set; }
|
||||||
[Parameter] public string GridName { get; set; }
|
[Parameter] public string GridName { get; set; }
|
||||||
[Parameter] public IId<TId>? ParentDataItem { 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 object[]? ContextIds { get; set; }
|
||||||
|
|
||||||
[Parameter] public string Caption { get; set; } = typeof(TDataItem).Name;
|
[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;
|
public bool IsMasterGrid => ParentDataItem == null;
|
||||||
protected PropertyInfo? KeyFieldPropertyInfoToParent;
|
protected PropertyInfo? KeyFieldPropertyInfoToParent;
|
||||||
|
|
@ -466,7 +475,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
||||||
await OnGridCustomizeEditModel.InvokeAsync(e);
|
await OnGridCustomizeEditModel.InvokeAsync(e);
|
||||||
|
|
||||||
// Update InfoPanel to edit mode
|
// Update InfoPanel to edit mode
|
||||||
InfoPanelInstance?.SetEditMode(editModel);
|
InfoPanelInstance?.SetEditMode(this, editModel);
|
||||||
|
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
@ -664,6 +673,14 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
||||||
{
|
{
|
||||||
if (GridName.IsNullOrWhiteSpace()) GridName = $"{typeof(TDataItem).Name}Grid";
|
if (GridName.IsNullOrWhiteSpace()) GridName = $"{typeof(TDataItem).Name}Grid";
|
||||||
_gridLogName = $"[{GridName}]";
|
_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;
|
IsFirstInitializeParameters = true;
|
||||||
}
|
}
|
||||||
|
|
@ -671,6 +688,59 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
||||||
base.OnParametersSet();
|
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 AddDataItem(TDataItem dataItem, int messageTag) => PostDataToServerAsync(dataItem, messageTag, TrackingState.Add);
|
||||||
|
|
||||||
public Task UpdateDataItem(TDataItem dataItem) => _dataSource.Update(dataItem, true);
|
public Task UpdateDataItem(TDataItem dataItem) => _dataSource.Update(dataItem, true);
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@
|
||||||
|
|
||||||
var underlyingType = Nullable.GetUnderlyingType(propertyInfo.PropertyType) ?? propertyInfo.PropertyType;
|
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);
|
RenderComboBoxEditor(builder, ref seq, dataItem, propertyInfo, comboSettings);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ namespace AyCode.Blazor.Components.Components.Grids;
|
||||||
public interface IInfoPanelBase
|
public interface IInfoPanelBase
|
||||||
{
|
{
|
||||||
void ClearEditMode();
|
void ClearEditMode();
|
||||||
void SetEditMode(object editModel);
|
void SetEditMode(IMgGridBase grid, object editModel);
|
||||||
void RefreshData(IMgGridBase grid, object? dataItem, int visibleIndex = -1);
|
void RefreshData(IMgGridBase grid, object? dataItem, int visibleIndex = -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,7 +59,7 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
|
||||||
|
|
||||||
private ElementReference _panelElement;
|
private ElementReference _panelElement;
|
||||||
private bool _isJsInitialized;
|
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 IMgGridBase? _currentGrid;
|
||||||
protected object? _currentDataItem;
|
protected object? _currentDataItem;
|
||||||
|
|
@ -70,16 +70,19 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
|
||||||
protected bool _isEditMode;
|
protected bool _isEditMode;
|
||||||
protected object? _editModel;
|
protected object? _editModel;
|
||||||
|
|
||||||
// Cache for edit settings to avoid repeated lookups
|
// Type-based caches for performance optimization
|
||||||
private readonly Dictionary<string, IEditSettings?> _editSettingsCache = [];
|
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)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
{
|
{
|
||||||
// Register this InfoPanel with the wrapper
|
|
||||||
GridWrapper?.RegisterInfoPanel(this);
|
GridWrapper?.RegisterInfoPanel(this);
|
||||||
|
|
||||||
await InitializeStickyAsync();
|
await InitializeStickyAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,42 +113,61 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
|
||||||
_currentGrid = grid;
|
_currentGrid = grid;
|
||||||
_currentDataItem = dataItem;
|
_currentDataItem = dataItem;
|
||||||
_focusedRowVisibleIndex = visibleIndex;
|
_focusedRowVisibleIndex = visibleIndex;
|
||||||
_editSettingsCache.Clear();
|
|
||||||
|
|
||||||
// Clear edit mode when refreshing with new data
|
// Clear edit mode when refreshing with new data
|
||||||
_isEditMode = false;
|
_isEditMode = false;
|
||||||
_editModel = null;
|
_editModel = null;
|
||||||
|
|
||||||
|
// Use cached columns if available
|
||||||
if (_currentGrid != null && _currentDataItem != null)
|
if (_currentGrid != null && _currentDataItem != null)
|
||||||
{
|
{
|
||||||
_allDataColumns = GetAllDataColumns(_currentGrid);
|
var dataItemType = _currentDataItem.GetType();
|
||||||
|
_allDataColumns = GetAllDataColumnsCached(dataItemType, _currentGrid);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_allDataColumns = [];
|
_allDataColumns = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
StateHasChanged();
|
// Batch state changes
|
||||||
|
if (!_pendingStateChange)
|
||||||
// Notify subscribers that data item changed
|
{
|
||||||
_ = OnDataItemChanged.InvokeAsync(dataItem);
|
_pendingStateChange = true;
|
||||||
|
InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
_pendingStateChange = false;
|
||||||
|
StateHasChanged();
|
||||||
|
await OnDataItemChanged.InvokeAsync(dataItem);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the InfoPanel to edit mode with the given edit model
|
/// Sets the InfoPanel to edit mode with the given edit model
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void SetEditMode(object editModel)
|
public void SetEditMode(IMgGridBase grid, object editModel)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(grid);
|
||||||
|
ArgumentNullException.ThrowIfNull(editModel);
|
||||||
|
|
||||||
|
_currentGrid = grid;
|
||||||
_editModel = editModel;
|
_editModel = editModel;
|
||||||
_isEditMode = true;
|
_isEditMode = true;
|
||||||
_currentDataItem = _editModel;
|
_currentDataItem = _editModel;
|
||||||
|
|
||||||
if (_currentGrid != null)
|
var dataItemType = _editModel.GetType();
|
||||||
{
|
_allDataColumns = GetAllDataColumnsCached(dataItemType, _currentGrid);
|
||||||
_allDataColumns = GetAllDataColumns(_currentGrid);
|
|
||||||
}
|
|
||||||
|
|
||||||
InvokeAsync(StateHasChanged);
|
// Batch state changes
|
||||||
|
if (!_pendingStateChange)
|
||||||
|
{
|
||||||
|
_pendingStateChange = true;
|
||||||
|
InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
_pendingStateChange = false;
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -155,8 +177,16 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
|
||||||
{
|
{
|
||||||
_isEditMode = false;
|
_isEditMode = false;
|
||||||
_editModel = null;
|
_editModel = null;
|
||||||
_editSettingsCache.Clear();
|
|
||||||
InvokeAsync(StateHasChanged);
|
if (!_pendingStateChange)
|
||||||
|
{
|
||||||
|
_pendingStateChange = true;
|
||||||
|
InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
_pendingStateChange = false;
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -168,7 +198,6 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
|
||||||
_currentDataItem = null;
|
_currentDataItem = null;
|
||||||
_focusedRowVisibleIndex = -1;
|
_focusedRowVisibleIndex = -1;
|
||||||
_allDataColumns = [];
|
_allDataColumns = [];
|
||||||
_editSettingsCache.Clear();
|
|
||||||
_isEditMode = false;
|
_isEditMode = false;
|
||||||
_editModel = null;
|
_editModel = null;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
|
@ -187,6 +216,11 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
|
||||||
// Ignore disposal errors
|
// Ignore disposal errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear all caches on dispose
|
||||||
|
_editSettingsCache.Clear();
|
||||||
|
_columnsCache.Clear();
|
||||||
|
_comboBoxTextCache.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -211,11 +245,13 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
|
||||||
if (value == null)
|
if (value == null)
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
|
|
||||||
|
var dataItemType = dataItem.GetType();
|
||||||
|
|
||||||
// Try to resolve display text from EditSettings
|
// Try to resolve display text from EditSettings
|
||||||
var editSettings = GetEditSettings(column.FieldName);
|
var editSettings = GetEditSettingsCached(dataItemType, column.FieldName);
|
||||||
if (editSettings is DxComboBoxSettings comboSettings)
|
if (editSettings is DxComboBoxSettings comboSettings)
|
||||||
{
|
{
|
||||||
var displayText = ResolveComboBoxDisplayText(comboSettings, value);
|
var displayText = ResolveComboBoxDisplayTextCached(dataItemType, comboSettings, value);
|
||||||
if (!string.IsNullOrEmpty(displayText))
|
if (!string.IsNullOrEmpty(displayText))
|
||||||
return displayText;
|
return displayText;
|
||||||
}
|
}
|
||||||
|
|
@ -242,14 +278,16 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets edit settings for the specified field (with caching)
|
/// Gets edit settings for the specified field with Type-based caching
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private IEditSettings? GetEditSettings(string fieldName)
|
private IEditSettings? GetEditSettingsCached(Type dataItemType, string fieldName)
|
||||||
{
|
{
|
||||||
if (_currentGrid == null || string.IsNullOrEmpty(fieldName))
|
if (_currentGrid == null || string.IsNullOrEmpty(fieldName))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if (_editSettingsCache.TryGetValue(fieldName, out var cached))
|
var cacheKey = (dataItemType, fieldName);
|
||||||
|
|
||||||
|
if (_editSettingsCache.TryGetValue(cacheKey, out var cached))
|
||||||
return cached;
|
return cached;
|
||||||
|
|
||||||
IEditSettings? settings = null;
|
IEditSettings? settings = null;
|
||||||
|
|
@ -269,10 +307,27 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
|
||||||
// Ignore errors
|
// Ignore errors
|
||||||
}
|
}
|
||||||
|
|
||||||
_editSettingsCache[fieldName] = settings;
|
_editSettingsCache[cacheKey] = settings;
|
||||||
return 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)
|
private string? ResolveComboBoxDisplayText(DxComboBoxSettings settings, object value)
|
||||||
{
|
{
|
||||||
if (settings.Data == null || string.IsNullOrEmpty(settings.ValueFieldName) || string.IsNullOrEmpty(settings.TextFieldName))
|
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)
|
protected static List<DxGridDataColumn> GetAllDataColumns(IMgGridBase grid)
|
||||||
{
|
{
|
||||||
var columns = new List<DxGridDataColumn>();
|
var columns = new List<DxGridDataColumn>();
|
||||||
|
|
@ -388,7 +457,11 @@ public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPan
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private EditSettingsType GetEditSettingsType(DxGridDataColumn column)
|
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
|
return settings switch
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue