Compare commits
No commits in common. "FruitBank_v0.0.8.0" and "master" have entirely different histories.
|
|
@ -1,43 +0,0 @@
|
||||||
# Topic Codes — registry for AyCode.Blazor's own topics (`ACBLAZOR`)
|
|
||||||
|
|
||||||
Per the Framework-First Design Principle, this Layer 1 registry lists **only AyCode.Blazor's own (`ACBLAZOR`) topics**. Lower-layer (inherited) topics live in their own repos' registries — at runtime, the `docs-check` skill walks `own-dep-repos` from the invocation point to gather all inherited topics. AyCode.Blazor inherits from `AyCode.Core` (see this repo's `@repo.own-dep-repos`).
|
|
||||||
|
|
||||||
Full ID format: `<PREFIX>-<TOPIC>-<TYPE>-<RAND>` — see `AyCode.Core/.github/REPO_PREFIXES.md` for the format spec.
|
|
||||||
|
|
||||||
## ACBLAZOR topic codes
|
|
||||||
|
|
||||||
| Code | Topic | Scope | Docs location |
|
|
||||||
|---------|-----------------------------|-----------------------------------------------------------------------------------|------------------------------------------------------------------------|
|
|
||||||
| `GRID` | MGGRID (grid component) | MgGrid component family: layout, CRUD, columns, toolbar, rendering | `AyCode.Blazor.Components/docs/MGGRID/` |
|
|
||||||
|
|
||||||
## Type codes (universal — see framework registry)
|
|
||||||
|
|
||||||
Type codes (`I`, `T`, `B`, `C`, `DEC`) are universal across all repos and defined in the framework's `TOPIC_CODES.md` (`AyCode.Core/.github/skills/docs-check/references/TOPIC_CODES.md`). This file does not duplicate them.
|
|
||||||
|
|
||||||
## ID format rules
|
|
||||||
|
|
||||||
See the framework's `TOPIC_CODES.md` (`AyCode.Core/.github/skills/docs-check/references/TOPIC_CODES.md`) for the full ID format rules and Status conventions. This file only registers ACBLAZOR's own topic codes; ACCORE topics are inherited via this repo's `own-dep-repos`.
|
|
||||||
|
|
||||||
## Examples (ACBLAZOR only)
|
|
||||||
|
|
||||||
```
|
|
||||||
ACBLAZOR-GRID-T-V4P7 # AyCode.Blazor's MgGrid TODO (e.g., "Generic ID generation in MgGridBase.SetNewId")
|
|
||||||
ACBLAZOR-GRID-T-S2L9 # AyCode.Blazor's MgGrid TODO (e.g., "Implement local grouping")
|
|
||||||
ACBLAZOR-GRID-I-XXXX # placeholder for the first MgGrid issue once one is observed
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding a new ACBLAZOR topic
|
|
||||||
|
|
||||||
1. Propose the code (2-5 uppercase chars), short and mnemonic, scoped to AyCode.Blazor's UI-framework domain.
|
|
||||||
2. Check it doesn't collide with C# class-name prefixes (`Ac*` / `Mg*`).
|
|
||||||
3. Check it doesn't collide with existing ACBLAZOR topic codes in the table above.
|
|
||||||
4. Check it doesn't visually collide with framework (`ACCORE-*`) topic codes that this repo references — though the `<PREFIX>` component disambiguates, visual distinction helps readers.
|
|
||||||
5. Add a row to the table above.
|
|
||||||
6. Create the topic folder under the relevant project: `AyCode.Blazor.<Project>/docs/{TOPIC_FOLDER_NAME}/` with `README.md`, optional `{TOPIC_FOLDER_NAME}_ISSUES.md`, `{TOPIC_FOLDER_NAME}_TODO.md`.
|
|
||||||
7. Optional: add an `LLMP-DEC-N` entry in the workspace-level `LLM_PROTOCOL_DECISIONS.md` if the new topic is workspace-meta-significant.
|
|
||||||
|
|
||||||
## Cross-references
|
|
||||||
|
|
||||||
- **Framework registry** (universal type codes, ID format spec, Status conventions): `AyCode.Core/.github/skills/docs-check/references/TOPIC_CODES.md` (resolved via this repo's `own-dep-repos`).
|
|
||||||
- **Repo prefix scheme**: `AyCode.Core/.github/REPO_PREFIXES.md`.
|
|
||||||
- **Decision Log**: `AyCode.Core/.github/LLM_PROTOCOL_DECISIONS.md`.
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,24 +0,0 @@
|
||||||
<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.4.2" />
|
|
||||||
<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>
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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; }
|
|
||||||
}
|
|
||||||
|
|
@ -1,226 +0,0 @@
|
||||||
using AyCode.Blazor.Components.Components.Grids;
|
|
||||||
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",
|
|
||||||
System.Action<DynamicColumnAddingEventArgs>? onDynamicColumnAttributeAdding = null)
|
|
||||||
{
|
|
||||||
var dataSource = new TestGridOrderItemDataSource(_client, _crudTags);
|
|
||||||
|
|
||||||
return Context!.Render<TestMgGridOrderItem>(parameters =>
|
|
||||||
{
|
|
||||||
parameters
|
|
||||||
.Add(p => p.DataSource, dataSource)
|
|
||||||
.Add(p => p.Logger, _logger)
|
|
||||||
.Add(p => p.SignalRClient, _client)
|
|
||||||
.Add(p => p.AutoSaveLayoutName, autoSaveLayoutName)
|
|
||||||
.Add(p => p.GetAllMessageTag, _crudTags.GetAllMessageTag)
|
|
||||||
.Add(p => p.AddMessageTag, _crudTags.AddMessageTag)
|
|
||||||
.Add(p => p.UpdateMessageTag, _crudTags.UpdateMessageTag)
|
|
||||||
.Add(p => p.RemoveMessageTag, _crudTags.RemoveMessageTag);
|
|
||||||
|
|
||||||
if (onDynamicColumnAttributeAdding != null)
|
|
||||||
{
|
|
||||||
parameters.Add(p => p.OnDynamicColumnAttributeAdding, onDynamicColumnAttributeAdding);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#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
|
|
||||||
|
|
||||||
#region MgGridDataColumn UrlLink Tests
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task MgGridDataColumn_UrlLink_ShouldRenderLinkWithReplacedValues()
|
|
||||||
{
|
|
||||||
// Arrange - Render grid with UrlLink on Id column
|
|
||||||
var cut = RenderTestGrid(onDynamicColumnAttributeAdding: args =>
|
|
||||||
{
|
|
||||||
if (args.FieldName == nameof(TestOrderItem.Id))
|
|
||||||
{
|
|
||||||
args.AdditionalAttributes[nameof(MgGridDataColumn.UrlLink)] = "https://example.com/edit/{Id}";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for data source to load
|
|
||||||
await cut.Instance.WaitForDataSourceLoadedAsync();
|
|
||||||
|
|
||||||
// Get the first row's Id value from the grid
|
|
||||||
var firstRowId = cut.Instance.GetRowValue(0, nameof(TestOrderItem.Id));
|
|
||||||
Assert.IsNotNull(firstRowId, "First row should have an Id value");
|
|
||||||
|
|
||||||
// Build the expected URL with the actual Id value
|
|
||||||
var expectedUrl = $"https://example.com/edit/{firstRowId}";
|
|
||||||
|
|
||||||
// Find the anchor element with the exact expected href
|
|
||||||
var anchor = cut.Find($"a[href=\"{expectedUrl}\"]");
|
|
||||||
|
|
||||||
// Assert - The anchor should exist and its text content should be the Id value
|
|
||||||
Assert.IsNotNull(anchor, $"Should find anchor with href='{expectedUrl}'");
|
|
||||||
Assert.AreEqual(firstRowId.ToString(), anchor.TextContent,
|
|
||||||
$"Anchor text should be the Id value '{firstRowId}', but was '{anchor.TextContent}'");
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
# Grids
|
|
||||||
|
|
||||||
Grid component integration tests for `MgGridBase` layout persistence, column rendering, and URL link functionality.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
- **`TestMgGrid.cs`** -- Test infrastructure for grid testing:
|
|
||||||
- `TestGridOrderItemDataSource` -- DataSource with the 3-parameter constructor required by `MgGridBase.OnInitializedAsync` via `Activator.CreateInstance`.
|
|
||||||
- `DynamicColumnAddingEventArgs` -- Event args for customizing dynamically added columns.
|
|
||||||
- `TestMgGridBase<...>` -- Abstract generic test grid that overrides layout persistence with in-memory `Dictionary` storage and auto-builds columns from `TDataItem` properties via reflection.
|
|
||||||
- `TestMgGridOrderItem` -- Concrete test grid bound to `TestOrderItem` entities.
|
|
||||||
- **`MgGridBaseTests.cs`** -- `[TestClass]` with tests for:
|
|
||||||
- Column width persistence across grid re-renders.
|
|
||||||
- Layout storage population after render.
|
|
||||||
- Separate layout keys for differently named grids.
|
|
||||||
- Master grid detection.
|
|
||||||
- Reflection-based column building (verifies Id, ProductName, Quantity columns).
|
|
||||||
- `MgGridDataColumn.UrlLink` rendering with token replacement.
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
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;
|
|
||||||
using Microsoft.AspNetCore.Components.Rendering;
|
|
||||||
|
|
||||||
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>
|
|
||||||
/// Event args for dynamic column adding event.
|
|
||||||
/// Provides a delegate to add custom attributes to the column.
|
|
||||||
/// </summary>
|
|
||||||
public class DynamicColumnAddingEventArgs
|
|
||||||
{
|
|
||||||
public required string FieldName { get; init; }
|
|
||||||
public required PropertyInfo PropertyInfo { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dictionary of additional attributes to add to the column.
|
|
||||||
/// Key is the attribute name, value is the attribute value.
|
|
||||||
/// </summary>
|
|
||||||
public Dictionary<string, object?> AdditionalAttributes { get; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Base test implementation of MgGridBase for testing grid functionality.
|
|
||||||
/// Overrides layout persistence to use in-memory storage for testing.
|
|
||||||
/// 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; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Event called when a dynamic column is being added. Allows customization of column properties.
|
|
||||||
/// Add attributes to eventArgs.AdditionalAttributes dictionary.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public Action<DynamicColumnAddingEventArgs>? OnDynamicColumnAttributeAdding { get; set; }
|
|
||||||
|
|
||||||
public void SetTestUserId(int userId) => _testUserId = userId;
|
|
||||||
public int GetTestUserId() => _testUserId;
|
|
||||||
protected override int GetLayoutUserId() => _testUserId;
|
|
||||||
|
|
||||||
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(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)
|
|
||||||
{
|
|
||||||
// Create event args and invoke the event
|
|
||||||
var eventArgs = new DynamicColumnAddingEventArgs
|
|
||||||
{
|
|
||||||
FieldName = property.Name,
|
|
||||||
PropertyInfo = property
|
|
||||||
};
|
|
||||||
OnDynamicColumnAttributeAdding?.Invoke(eventArgs);
|
|
||||||
|
|
||||||
builder.OpenComponent<MgGridDataColumn>(seq++);
|
|
||||||
builder.AddAttribute(seq++, nameof(MgGridDataColumn.Name), property.Name);
|
|
||||||
builder.AddAttribute(seq++, nameof(MgGridDataColumn.FieldName), property.Name);
|
|
||||||
builder.AddAttribute(seq++, nameof(MgGridDataColumn.Width), GetDefaultWidth(property.PropertyType));
|
|
||||||
|
|
||||||
// Add additional attributes from the event
|
|
||||||
foreach (var attr in eventArgs.AdditionalAttributes)
|
|
||||||
{
|
|
||||||
builder.AddAttribute(seq++, attr.Key, attr.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.CloseComponent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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>
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
# AyCode.Blazor.Components.Tests
|
|
||||||
|
|
||||||
@project {
|
|
||||||
type = "test"
|
|
||||||
}
|
|
||||||
|
|
||||||
bUnit + MSTest component test project targeting net10.0. Tests Blazor component behavior including grid layout persistence and column rendering.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
- **`MSTestSettings.cs`** -- Assembly-level config enabling method-level test parallelization.
|
|
||||||
- **`BunitTestContext.cs`** -- Abstract base classes (`BunitTestContext`, `TestContextWrapper`) that wire up bUnit `BunitContext` creation and disposal to MSTest `[TestInitialize]`/`[TestCleanup]` lifecycle.
|
|
||||||
- **`Grids/`** -- Grid component integration tests (see [Grids/README.md](Grids/README.md)).
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
| Dependency | Version | Type |
|
|
||||||
|---|---|---|
|
|
||||||
| bunit | 2.4.2 | NuGet |
|
|
||||||
| MSTest | 4.0.2 | NuGet |
|
|
||||||
| AyCode.Blazor.Components | -- | ProjectReference |
|
|
||||||
| AyCode.Services.Server.Tests | -- | ProjectReference |
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
root = true
|
|
||||||
|
|
||||||
# CSS files
|
|
||||||
[*.css]
|
|
||||||
# Disable warnings for modern CSS features
|
|
||||||
css_disable_validation_warnings = true
|
|
||||||
css_schema_version = css3
|
|
||||||
|
|
||||||
# Allow container queries and modern CSS
|
|
||||||
css_container_queries = true
|
|
||||||
css_custom_properties = true
|
|
||||||
|
|
@ -1,68 +1,53 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<RunAOTCompilation>true</RunAOTCompilation>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<WasmStripILAfterAOT>true</WasmStripILAfterAOT>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<Import Project="..//AyCode.Blazor.targets" />
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<SupportedPlatform Include="browser" />
|
<SupportedPlatform Include="browser" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="DevExpress.Blazor" Version="25.1.3" />
|
<PackageReference Include="DevExpress.Blazor" Version="24.1.3" />
|
||||||
<PackageReference Include="DevExpress.Data" Version="25.1.3" />
|
<PackageReference Include="DevExpress.Blazor.Dashboard" Version="24.1.3" />
|
||||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
<PackageReference Include="MessagePack" Version="2.5.187" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="8.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.10" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Core" Version="1.2.0" />
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.11" />
|
<Reference Include="AyCode.Core">
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.11" />
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Entities">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Interfaces">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Models">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Services">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Services.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Utils">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Utils.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0" />
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
<Folder Include="Layouts\" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.0" />
|
<Folder Include="Pages\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="AyCode.Core">
|
<ProjectReference Include="..\AyCode.Blazor.Models\AyCode.Blazor.Models.csproj" />
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.dll</HintPath>
|
</ItemGroup>
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Entities">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Entities.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Interfaces">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Interfaces.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Models">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Models.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Services">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Services.Server">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.Server.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Utils">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Utils.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="Layouts\" />
|
|
||||||
<Folder Include="Pages\" />
|
|
||||||
<Folder Include="Services\Logins\" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<None Include="docs\**\*.md" />
|
|
||||||
<None Include="**\README.md" Exclude="$(DefaultItemExcludes);docs\**" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\AyCode.Blazor.Models.Server\AyCode.Blazor.Models.Server.csproj" />
|
|
||||||
<ProjectReference Include="..\AyCode.Blazor.Models\AyCode.Blazor.Models.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<SupportedPlatform Include="browser" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="DevExpress.Blazor" Version="23.2.3" />
|
||||||
|
<PackageReference Include="DevExpress.Blazor.Dashboard" Version="23.2.3" />
|
||||||
|
<PackageReference Include="MessagePack" Version="2.5.168" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="8.0.6" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.6" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.6" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="AyCode.Core">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Entities">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Interfaces">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Models">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Services">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Services.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Utils">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Utils.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Layouts\" />
|
||||||
|
<Folder Include="Pages\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\AyCode.Blazor.Models\AyCode.Blazor.Models.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
@typeparam TItem
|
|
||||||
|
|
||||||
<div class="mg-card-view-container" style="@ContainerStyle">
|
|
||||||
@if (ShowFilterPanel && FilterPanel is not null)
|
|
||||||
{
|
|
||||||
<div class="mg-card-filter-panel">
|
|
||||||
@FilterPanel
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (Data is { Count: > 0 })
|
|
||||||
{
|
|
||||||
<div class="mg-card-scroll-area">
|
|
||||||
<div class="mg-card-grid @CssClass"
|
|
||||||
style="--cols-xs: @ColumnCountXs; --cols-sm: @ColumnCountSm; --cols-lg: @ColumnCountLg;">
|
|
||||||
@foreach (var item in PagedItems)
|
|
||||||
{
|
|
||||||
<div id="@GetCardElementId(item)"
|
|
||||||
class="mg-card @CardCssClass"
|
|
||||||
@onclick="() => OnCardClickInternal(item)"
|
|
||||||
style="@(OnCardClick.HasDelegate ? "cursor: pointer;" : "")">
|
|
||||||
@CardTemplate(item)
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (ShowPager && Data.Count > PageSize)
|
|
||||||
{
|
|
||||||
<DxPager PageCount="@((int)Math.Ceiling((double)Data.Count / PageSize))"
|
|
||||||
ActivePageIndex="_activePageIndex"
|
|
||||||
ActivePageIndexChanged="OnActivePageIndexChanged"
|
|
||||||
CssClass="mt-2" />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
using Microsoft.JSInterop;
|
|
||||||
|
|
||||||
namespace AyCode.Blazor.Components.Components.CardViews;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generic card view component that displays items in a responsive CSS Grid layout
|
|
||||||
/// with optional pagination and scroll-to-item support.
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="TItem">The type of data item displayed in each card.</typeparam>
|
|
||||||
public partial class MgCardView<TItem> : ComponentBase
|
|
||||||
{
|
|
||||||
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The collection of items to display as cards.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter, EditorRequired]
|
|
||||||
public IReadOnlyList<TItem> Data { get; set; } = [];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Template for rendering each card's content.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter, EditorRequired]
|
|
||||||
public RenderFragment<TItem> CardTemplate { get; set; } = null!;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fired when a card is clicked/tapped.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback<TItem> OnCardClick { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Number of columns on extra-small screens (below 576px). Default: 1.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public int ColumnCountXs { get; set; } = 1;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Number of columns on small screens (576–768px). Default: 2.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public int ColumnCountSm { get; set; } = 2;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Number of columns on medium+ screens (769px+). Default: 3.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public int ColumnCountLg { get; set; } = 3;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether to show the pager below the cards. Default: false.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public bool ShowPager { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Number of items per page when paging is enabled. Default: 12.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public int PageSize { get; set; } = 12;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Additional CSS class for the card view container.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public string? CssClass { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Additional CSS class applied to each individual card wrapper.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public string? CardCssClass { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Height of the card view container (e.g., "500px", "70vh"). When set, the component uses its own scroll area.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public string? Height { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether to show the filter panel above the cards. Default: false.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public bool ShowFilterPanel { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Custom content for the filter panel. Rendered above the card grid when ShowFilterPanel is true.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public RenderFragment? FilterPanel { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Item to scroll into view after render. Set to null to disable.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public TItem? ScrollToItem { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Key selector for identifying items (e.g., item => item.Id). Required when ScrollToItem is used.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public Func<TItem, object>? ItemKeySelector { get; set; }
|
|
||||||
|
|
||||||
private int _activePageIndex;
|
|
||||||
private object? _lastScrolledKey;
|
|
||||||
|
|
||||||
private string? ContainerStyle => Height is not null ? $"height: {Height};" : null;
|
|
||||||
|
|
||||||
private IReadOnlyList<TItem> PagedItems
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
if (!ShowPager)
|
|
||||||
return Data;
|
|
||||||
|
|
||||||
return Data
|
|
||||||
.Skip(_activePageIndex * PageSize)
|
|
||||||
.Take(PageSize)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnCardClickInternal(TItem item)
|
|
||||||
{
|
|
||||||
if (OnCardClick.HasDelegate)
|
|
||||||
await OnCardClick.InvokeAsync(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnActivePageIndexChanged(int newPageIndex)
|
|
||||||
{
|
|
||||||
_activePageIndex = newPageIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a stable DOM element id for a card item using the key selector.
|
|
||||||
/// </summary>
|
|
||||||
private string? GetCardElementId(TItem item)
|
|
||||||
{
|
|
||||||
return ItemKeySelector is null ? null : $"mg-card-{ItemKeySelector(item)}";
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
||||||
{
|
|
||||||
if (ScrollToItem is not null && ItemKeySelector is not null)
|
|
||||||
{
|
|
||||||
var key = ItemKeySelector(ScrollToItem);
|
|
||||||
if (!Equals(key, _lastScrolledKey))
|
|
||||||
{
|
|
||||||
_lastScrolledKey = key;
|
|
||||||
var elementId = $"mg-card-{key}";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await JSRuntime.InvokeVoidAsync("MgCardView.scrollToElement", elementId);
|
|
||||||
}
|
|
||||||
catch (JSException)
|
|
||||||
{
|
|
||||||
// JS might not be loaded yet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
.mg-card-view-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-card-scroll-area {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-card-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
grid-template-columns: repeat(var(--cols-xs, 1), 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 576px) {
|
|
||||||
.mg-card-grid {
|
|
||||||
grid-template-columns: repeat(var(--cols-sm, 2), 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 769px) {
|
|
||||||
.mg-card-grid {
|
|
||||||
grid-template-columns: repeat(var(--cols-lg, 3), 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-card {
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
background-color: #fff;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
||||||
transition: box-shadow 0.2s ease, transform 0.15s ease;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-card:hover {
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-card-filter-panel {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
# CardViews
|
|
||||||
|
|
||||||
Generic card-based view component that renders items in a responsive CSS Grid layout with optional pagination, filtering, and scroll-to-item support.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
- **`MgCardView.razor.cs`** -- `MgCardView<TItem>` component. Accepts `Data`, `CardTemplate`, responsive column counts (`ColumnCountXs/Sm/Lg`), optional pager (`ShowPager`, `PageSize` default 12), optional filter panel, container height, and `ScrollToItem` with JS interop for auto-scrolling. Fires `OnCardClick` when a card is tapped.
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
namespace AyCode.Blazor.Components.Components.Grids;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents the current edit state of the MgGrid
|
|
||||||
/// </summary>
|
|
||||||
public enum MgGridEditState
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// No edit operation in progress
|
|
||||||
/// </summary>
|
|
||||||
None,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adding a new row
|
|
||||||
/// </summary>
|
|
||||||
New,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Editing an existing row
|
|
||||||
/// </summary>
|
|
||||||
Edit
|
|
||||||
}
|
|
||||||
|
|
@ -1,982 +0,0 @@
|
||||||
using AyCode.Core;
|
|
||||||
using AyCode.Core.Enums;
|
|
||||||
using AyCode.Core.Helpers;
|
|
||||||
using AyCode.Core.Interfaces;
|
|
||||||
using AyCode.Core.Loggers;
|
|
||||||
using AyCode.Services.Server.SignalRs;
|
|
||||||
using AyCode.Services.SignalRs;
|
|
||||||
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;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using AyCode.Core.Compression;
|
|
||||||
|
|
||||||
namespace AyCode.Blazor.Components.Components.Grids;
|
|
||||||
|
|
||||||
public interface IMgGridBase : IGrid
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Indicates whether any synchronization operation is in progress
|
|
||||||
/// </summary>
|
|
||||||
bool IsSyncing { get; }
|
|
||||||
|
|
||||||
string Caption { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Current edit state of the grid (None, New, Edit)
|
|
||||||
/// </summary>
|
|
||||||
MgGridEditState GridEditState { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parent grid in nested grid hierarchy (null if this is a root grid)
|
|
||||||
/// </summary>
|
|
||||||
IMgGridBase? ParentGrid { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the root grid in the hierarchy
|
|
||||||
/// </summary>
|
|
||||||
IMgGridBase GetRootGrid();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Navigates to the previous row in the grid
|
|
||||||
/// </summary>
|
|
||||||
void StepPrevRow();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Navigates to the next row in the grid
|
|
||||||
/// </summary>
|
|
||||||
void StepNextRow();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// InfoPanel instance for displaying row details (from wrapper)
|
|
||||||
/// </summary>
|
|
||||||
IInfoPanelBase? InfoPanelInstance { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether the grid/wrapper is currently in fullscreen mode
|
|
||||||
/// </summary>
|
|
||||||
bool IsFullscreen { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Storage key for automatic layout persistence
|
|
||||||
/// </summary>
|
|
||||||
string AutomaticLayoutStorageKey { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Toggles fullscreen mode for the grid (or wrapper if available)
|
|
||||||
/// </summary>
|
|
||||||
void ToggleFullscreen();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Saves the current layout to user storage (manual save)
|
|
||||||
/// </summary>
|
|
||||||
Task SaveUserLayoutAsync();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Loads layout from user storage (manual load)
|
|
||||||
/// </summary>
|
|
||||||
Task LoadUserLayoutAsync();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resets the layout by clearing auto-saved layout and reloading the page
|
|
||||||
/// </summary>
|
|
||||||
Task ResetLayoutAsync();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a user-saved layout exists without loading it
|
|
||||||
/// </summary>
|
|
||||||
Task<bool> HasUserLayoutAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient> : DxGrid, IMgGridBase, IAsyncDisposable
|
|
||||||
where TSignalRDataSource : AcSignalRDataSource<TDataItem, TId, AcObservableCollection<TDataItem>>
|
|
||||||
where TDataItem : class, IId<TId>
|
|
||||||
where TId : struct
|
|
||||||
where TLoggerClient : AcLoggerBase
|
|
||||||
{
|
|
||||||
private readonly EqualityComparer<TId> _equalityComparerId = EqualityComparer<TId>.Default;
|
|
||||||
private readonly TypeConverter _typeConverterId = TypeDescriptor.GetConverter(typeof(TId));
|
|
||||||
|
|
||||||
protected bool IsFirstInitializeParameters;
|
|
||||||
protected bool IsFirstInitializeParameterCore;
|
|
||||||
private bool _isDisposed;
|
|
||||||
private Guid _gridRenderKey = Guid.NewGuid();
|
|
||||||
|
|
||||||
private TSignalRDataSource? _dataSource = null!;
|
|
||||||
private AcObservableCollection<TDataItem>? _dataSourceParam = [];
|
|
||||||
private string _gridLogName;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public bool IsSyncing => _dataSource?.IsSyncing ?? false;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public MgGridEditState GridEditState { get; private set; } = MgGridEditState.None;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
[CascadingParameter]
|
|
||||||
public IMgGridBase? ParentGrid { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IMgGridBase GetRootGrid()
|
|
||||||
{
|
|
||||||
var current = (IMgGridBase)this;
|
|
||||||
while (current.ParentGrid != null)
|
|
||||||
{
|
|
||||||
current = current.ParentGrid;
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the user layout storage key (replaces AutoSave with UserSave)
|
|
||||||
/// </summary>
|
|
||||||
private string UserLayoutStorageKey => AutomaticLayoutStorageKey.Replace("_AutoSave_", "_UserSave_");
|
|
||||||
|
|
||||||
public string AutomaticLayoutStorageKey
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var masterDetailName = IsMasterGrid ? "Master" : ParentDataItem!.GetType().Name;
|
|
||||||
return $"{AutoSaveLayoutName}_{masterDetailName}_AutoSave_{GetLayoutUserId()}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reference to the wrapper component for grid-InfoPanel communication
|
|
||||||
/// </summary>
|
|
||||||
[CascadingParameter]
|
|
||||||
public MgGridWithInfoPanel? GridWrapper { get; set; }
|
|
||||||
|
|
||||||
private object _focusedDataItem;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// InfoPanel instance for displaying row details.
|
|
||||||
/// First checks own wrapper, then gets InfoPanel from root grid.
|
|
||||||
/// </summary>
|
|
||||||
public IInfoPanelBase? InfoPanelInstance
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
// First check if we have a direct wrapper with InfoPanel
|
|
||||||
if (GridWrapper?.InfoPanelInstance != null)
|
|
||||||
return GridWrapper.InfoPanelInstance;
|
|
||||||
|
|
||||||
// Get InfoPanel from root grid (handles nested grids)
|
|
||||||
var rootGrid = GetRootGrid();
|
|
||||||
if (rootGrid != this)
|
|
||||||
return rootGrid.InfoPanelInstance;
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public bool IsFullscreen => GridWrapper?.IsFullscreen ?? _isStandaloneFullscreen;
|
|
||||||
|
|
||||||
private bool _isStandaloneFullscreen;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void ToggleFullscreen()
|
|
||||||
{
|
|
||||||
if (GridWrapper != null)
|
|
||||||
{
|
|
||||||
// Ha van wrapper, azt váltjuk fullscreen-be
|
|
||||||
GridWrapper.ToggleFullscreen();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Ha nincs wrapper, saját fullscreen állapotot használunk
|
|
||||||
_isStandaloneFullscreen = !_isStandaloneFullscreen;
|
|
||||||
InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public MgGridBase() : base()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void BuildRenderTree(RenderTreeBuilder builder)
|
|
||||||
{
|
|
||||||
var seq = 0;
|
|
||||||
|
|
||||||
// Wrap everything in a CascadingValue to provide this grid as ParentGrid to nested grids
|
|
||||||
builder.OpenComponent<CascadingValue<IMgGridBase>>(seq++);
|
|
||||||
builder.AddAttribute(seq++, "Value", (IMgGridBase)this);
|
|
||||||
builder.AddAttribute(seq++, "ChildContent", (RenderFragment)(contentBuilder =>
|
|
||||||
{
|
|
||||||
if (_isStandaloneFullscreen && GridWrapper == null)
|
|
||||||
{
|
|
||||||
// Standalone fullscreen mode - Bootstrap 5 fullscreen overlay
|
|
||||||
contentBuilder.OpenElement(0, "div");
|
|
||||||
contentBuilder.AddAttribute(1, "class", "mg-fullscreen-overlay");
|
|
||||||
contentBuilder.SetKey(_gridRenderKey);
|
|
||||||
|
|
||||||
// Header
|
|
||||||
contentBuilder.OpenElement(2, "div");
|
|
||||||
contentBuilder.AddAttribute(3, "class", "mg-fullscreen-header");
|
|
||||||
|
|
||||||
contentBuilder.OpenElement(4, "span");
|
|
||||||
contentBuilder.AddAttribute(5, "class", "mg-fullscreen-title");
|
|
||||||
contentBuilder.AddContent(6, Caption);
|
|
||||||
contentBuilder.CloseElement(); // span
|
|
||||||
|
|
||||||
contentBuilder.OpenElement(7, "button");
|
|
||||||
contentBuilder.AddAttribute(8, "type", "button");
|
|
||||||
contentBuilder.AddAttribute(9, "class", "btn-close btn-close-white");
|
|
||||||
contentBuilder.AddAttribute(10, "aria-label", "Close");
|
|
||||||
contentBuilder.AddAttribute(11, "onclick", EventCallback.Factory.Create<Microsoft.AspNetCore.Components.Web.MouseEventArgs>(this, () =>
|
|
||||||
{
|
|
||||||
_isStandaloneFullscreen = false;
|
|
||||||
InvokeAsync(StateHasChanged);
|
|
||||||
}));
|
|
||||||
contentBuilder.CloseElement(); // button
|
|
||||||
|
|
||||||
contentBuilder.CloseElement(); // header div
|
|
||||||
|
|
||||||
// Body
|
|
||||||
contentBuilder.OpenElement(12, "div");
|
|
||||||
contentBuilder.AddAttribute(13, "class", "mg-fullscreen-body");
|
|
||||||
base.BuildRenderTree(contentBuilder);
|
|
||||||
contentBuilder.CloseElement(); // body div
|
|
||||||
|
|
||||||
contentBuilder.CloseElement(); // overlay div
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Normal mode - use key for forced re-render on reset
|
|
||||||
contentBuilder.OpenElement(0, "div");
|
|
||||||
contentBuilder.SetKey(_gridRenderKey);
|
|
||||||
contentBuilder.AddAttribute(1, "style", "display: contents;");
|
|
||||||
base.BuildRenderTree(contentBuilder);
|
|
||||||
contentBuilder.CloseElement();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
builder.CloseComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected bool HasIdValue(TDataItem dataItem) => HasIdValue(dataItem.Id);
|
|
||||||
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; }
|
|
||||||
[Parameter] public string? KeyFieldNameToParentId { get; set; }
|
|
||||||
[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;
|
|
||||||
|
|
||||||
private string? _filterText = null;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string? FilterText
|
|
||||||
{
|
|
||||||
get => _filterText;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
_filterText = value;
|
|
||||||
|
|
||||||
if (_dataSource != null && _dataSource.FilterText != value)
|
|
||||||
{
|
|
||||||
_dataSource.FilterText = value;
|
|
||||||
ReloadDataSourceAsync().Forget(Logger);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Parameter] public AcSignalRClientBase SignalRClient { get; set; }
|
|
||||||
|
|
||||||
[Parameter] public int GetAllMessageTag { get; set; }
|
|
||||||
[Parameter] public int GetItemMessageTag { get; set; }
|
|
||||||
[Parameter] public int AddMessageTag { get; set; }
|
|
||||||
[Parameter] public int UpdateMessageTag { get; set; }
|
|
||||||
[Parameter] public int RemoveMessageTag { get; set; }
|
|
||||||
|
|
||||||
protected new EventCallback<GridDataItemDeletingEventArgs> DataItemDeleting { get; set; }
|
|
||||||
[Parameter] public EventCallback<GridDataItemDeletingEventArgs> OnGridItemDeleting { get; set; }
|
|
||||||
|
|
||||||
protected new EventCallback<GridEditModelSavingEventArgs> EditModelSaving { get; set; }
|
|
||||||
[Parameter] public EventCallback<GridEditModelSavingEventArgs> OnGridEditModelSaving { get; set; }
|
|
||||||
|
|
||||||
protected new EventCallback<GridEditStartEventArgs> EditStart { get; set; }
|
|
||||||
[Parameter] public EventCallback<GridEditStartEventArgs> OnGridEditStart { get; set; }
|
|
||||||
|
|
||||||
protected new EventCallback<GridCustomizeEditModelEventArgs> CustomizeEditModel { get; set; }
|
|
||||||
[Parameter] public EventCallback<GridCustomizeEditModelEventArgs> OnGridCustomizeEditModel { get; set; }
|
|
||||||
|
|
||||||
protected new EventCallback<GridFocusedRowChangedEventArgs> FocusedRowChanged { get; set; }
|
|
||||||
[Parameter] public EventCallback<GridFocusedRowChangedEventArgs> OnGridFocusedRowChanged { get; set; }
|
|
||||||
|
|
||||||
[Parameter] public EventCallback<IList<TDataItem>> OnDataSourceChanged { get; set; }
|
|
||||||
[Parameter] public EventCallback<GridDataItemChangingEventArgs<TDataItem>> OnGridItemChanging { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// After the server has responded!
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback<GridDataItemChangedEventArgs<TDataItem>> OnGridItemChanged { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
[DefaultValue(null)]
|
|
||||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "BL0007:Component parameters should be auto properties", Justification = "<Pending>")]
|
|
||||||
public IList<TDataItem> DataSource
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
if (_dataSource == null && Data != null)
|
|
||||||
{
|
|
||||||
Logger.Error($"{_gridLogName} Use the DataSource parameter instead of Data!");
|
|
||||||
throw new NullReferenceException($"{_gridLogName} Use the DataSource parameter instead of Data!");
|
|
||||||
}
|
|
||||||
|
|
||||||
return _dataSource!;
|
|
||||||
}
|
|
||||||
set
|
|
||||||
{
|
|
||||||
_dataSourceParam = value as AcObservableCollection<TDataItem>;
|
|
||||||
|
|
||||||
if (_dataSource != null) // && _dataSourceParam is List<TDataItem> workingReferenceList)
|
|
||||||
{
|
|
||||||
SetWorkingReferenceList(_dataSourceParam);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetWorkingReferenceList(AcObservableCollection<TDataItem>? referenceList)
|
|
||||||
{
|
|
||||||
_dataSource?.SetWorkingReferenceList(referenceList);
|
|
||||||
|
|
||||||
SetGridData(referenceList);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetGridData(object? data)
|
|
||||||
{
|
|
||||||
if (_isDisposed) return;
|
|
||||||
if (ReferenceEquals(Data, data)) return;
|
|
||||||
|
|
||||||
BeginUpdate();
|
|
||||||
Data = data;
|
|
||||||
EndUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
if (Logger == null)
|
|
||||||
throw new NullReferenceException($"[{GetType().Name}] Logger == null");
|
|
||||||
|
|
||||||
if (SignalRClient == null)
|
|
||||||
{
|
|
||||||
Logger.Error($"[{GetType().Name}] SignalRClient == null");
|
|
||||||
throw new NullReferenceException($"[{GetType().Name}] SignalRClient == null");
|
|
||||||
}
|
|
||||||
|
|
||||||
var crudTags = new SignalRCrudTags(GetAllMessageTag, GetItemMessageTag, AddMessageTag, UpdateMessageTag, RemoveMessageTag);
|
|
||||||
|
|
||||||
_dataSource = (TSignalRDataSource)Activator.CreateInstance(typeof(TSignalRDataSource), SignalRClient, crudTags, Logger, ContextIds)!;
|
|
||||||
_dataSource.FilterText = FilterText;
|
|
||||||
|
|
||||||
SetGridData(_dataSource.GetReferenceInnerList());
|
|
||||||
|
|
||||||
_dataSource.OnDataSourceLoaded += OnDataSourceLoaded;
|
|
||||||
_dataSource.OnDataSourceItemChanged += OnDataSourceItemChanged;
|
|
||||||
_dataSource.OnSyncingStateChanged += OnDataSourceSyncingStateChanged;
|
|
||||||
|
|
||||||
await base.OnInitializedAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDataSourceSyncingStateChanged(bool isSyncing)
|
|
||||||
{
|
|
||||||
if (_isDisposed) return;
|
|
||||||
|
|
||||||
// Forward the event to external subscribers
|
|
||||||
//OnSyncingStateChanged?.Invoke(isSyncing);
|
|
||||||
|
|
||||||
// Trigger UI update
|
|
||||||
InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnDataSourceItemChanged(ItemChangedEventArgs<TDataItem> args)
|
|
||||||
{
|
|
||||||
if (_isDisposed) return;
|
|
||||||
if (args.TrackingState is TrackingState.GetAll or TrackingState.None) return;
|
|
||||||
|
|
||||||
Logger.Debug($"{_gridLogName} OnDataSourceItemChanged; trackingState: {args.TrackingState}");
|
|
||||||
|
|
||||||
var changedEventArgs = new GridDataItemChangedEventArgs<TDataItem>(this, args.Item, args.TrackingState);
|
|
||||||
await OnGridItemChanged.InvokeAsync(changedEventArgs);
|
|
||||||
|
|
||||||
if (!changedEventArgs.CancelStateChangeInvoke && !_isDisposed)
|
|
||||||
{
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnDataSourceLoaded()
|
|
||||||
{
|
|
||||||
if (_isDisposed) return;
|
|
||||||
|
|
||||||
Logger.Debug($"{_gridLogName} OnDataSourceLoaded; Count: {_dataSource?.Count}");
|
|
||||||
|
|
||||||
await InvokeAsync(() => SetGridData(_dataSource!.GetReferenceInnerList()));
|
|
||||||
|
|
||||||
if (!_isDisposed)
|
|
||||||
{
|
|
||||||
await OnDataSourceChanged.InvokeAsync(_dataSource);
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
||||||
{
|
|
||||||
if (firstRender)
|
|
||||||
{
|
|
||||||
if (_dataSource == null) return;
|
|
||||||
|
|
||||||
if (_dataSourceParam != null) await _dataSource.LoadDataSource(_dataSourceParam, true, true);
|
|
||||||
else _dataSource.LoadDataSourceAsync(true).Forget(Logger);
|
|
||||||
}
|
|
||||||
|
|
||||||
await base.OnAfterRenderAsync(firstRender);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetNewId(TDataItem dataItem)
|
|
||||||
{
|
|
||||||
//TODO: int !!! - J.
|
|
||||||
if (dataItem.Id is Guid)
|
|
||||||
{
|
|
||||||
dataItem.Id = (TId)(_typeConverterId.ConvertTo(Guid.NewGuid(), typeof(TId)))!;
|
|
||||||
}
|
|
||||||
else if (dataItem.Id is int)
|
|
||||||
{
|
|
||||||
var newId = -1 * AcDomain.NextUniqueInt32;
|
|
||||||
dataItem.Id = (TId)(_typeConverterId.ConvertTo(newId, typeof(TId)))!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task AddDataItem(TDataItem dataItem)
|
|
||||||
{
|
|
||||||
if (!HasIdValue(dataItem)) SetNewId(dataItem);
|
|
||||||
return _dataSource.Add(dataItem, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task AddDataItemAsync(TDataItem dataItem)
|
|
||||||
{
|
|
||||||
if (!HasIdValue(dataItem)) SetNewId(dataItem);
|
|
||||||
_dataSource.Add(dataItem);
|
|
||||||
|
|
||||||
return SaveChangesToServerAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task InsertDataItem(int index, TDataItem dataItem)
|
|
||||||
{
|
|
||||||
if (!HasIdValue(dataItem)) SetNewId(dataItem);
|
|
||||||
return _dataSource.Insert(index, dataItem, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task InsertDataItemAsync(int index, TDataItem dataItem)
|
|
||||||
{
|
|
||||||
if (!HasIdValue(dataItem)) SetNewId(dataItem);
|
|
||||||
_dataSource.Insert(index, dataItem);
|
|
||||||
|
|
||||||
return SaveChangesToServerAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected PropertyInfo? GetDataItemPropertyInfo(string propertyName)
|
|
||||||
=> typeof(TDataItem).GetProperty(propertyName);
|
|
||||||
|
|
||||||
protected virtual async Task OnCustomizeEditModel(GridCustomizeEditModelEventArgs e)
|
|
||||||
{
|
|
||||||
var editModel = (e.EditModel as TDataItem)!;
|
|
||||||
|
|
||||||
if (e.IsNew)
|
|
||||||
{
|
|
||||||
if (!HasIdValue(editModel)) SetNewId(editModel);
|
|
||||||
|
|
||||||
if (ParentDataItem != null && !KeyFieldNameToParentId.IsNullOrWhiteSpace())
|
|
||||||
{
|
|
||||||
KeyFieldPropertyInfoToParent ??= GetDataItemPropertyInfo(KeyFieldNameToParentId);
|
|
||||||
KeyFieldPropertyInfoToParent!.SetValue(editModel, ParentDataItem.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
e.EditModel = editModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set edit state
|
|
||||||
GridEditState = e.IsNew ? MgGridEditState.New : MgGridEditState.Edit;
|
|
||||||
|
|
||||||
await OnGridCustomizeEditModel.InvokeAsync(e);
|
|
||||||
|
|
||||||
// Update InfoPanel to edit mode
|
|
||||||
InfoPanelInstance?.SetEditMode(this, editModel);
|
|
||||||
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnEditStart(GridEditStartEventArgs e)
|
|
||||||
{
|
|
||||||
await OnGridEditStart.InvokeAsync(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected virtual async Task OnFocusedRowChanged(GridFocusedRowChangedEventArgs e)
|
|
||||||
{
|
|
||||||
_focusedDataItem = e.DataItem;
|
|
||||||
|
|
||||||
var infoPanelInstance = InfoPanelInstance;
|
|
||||||
|
|
||||||
if (infoPanelInstance != null && e.DataItem != null)
|
|
||||||
{
|
|
||||||
// Ha edit módban vagyunk, de a felhasználó egy másik sorra kattintott,
|
|
||||||
// akkor kilépünk az edit módból
|
|
||||||
if (GridEditState != MgGridEditState.None)
|
|
||||||
{
|
|
||||||
infoPanelInstance.ClearEditMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Frissítjük az InfoPanel-t az új sor adataival
|
|
||||||
infoPanelInstance.RefreshData(this, e.DataItem, e.VisibleIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
await OnGridFocusedRowChanged.InvokeAsync(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnItemSaving(GridEditModelSavingEventArgs e)
|
|
||||||
{
|
|
||||||
var dataItem = (e.EditModel as TDataItem)!;
|
|
||||||
|
|
||||||
if (e.IsNew)
|
|
||||||
{
|
|
||||||
if (!HasIdValue(dataItem)) SetNewId(dataItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
var logText = e.IsNew ? "add" : "update";
|
|
||||||
Logger.Debug($"{_gridLogName} OnItemSaving {logText}; Id: {dataItem.Id}");
|
|
||||||
|
|
||||||
await OnGridEditModelSaving.InvokeAsync(e);
|
|
||||||
|
|
||||||
if (e.Cancel)
|
|
||||||
{
|
|
||||||
Logger.Debug($"{_gridLogName} OnItemSaving {logText} canceled; Id: {dataItem.Id}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.IsNew)
|
|
||||||
{
|
|
||||||
if (EditNewRowPosition is GridEditNewRowPosition.FixedOnTop or GridEditNewRowPosition.Top) await AddDataItemAsync(dataItem);
|
|
||||||
else await InsertDataItemAsync(0, dataItem);
|
|
||||||
}
|
|
||||||
else await UpdateDataItemAsync(dataItem);
|
|
||||||
|
|
||||||
GridEditState = MgGridEditState.None;
|
|
||||||
|
|
||||||
InfoPanelInstance?.ClearEditMode();
|
|
||||||
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnEditCanceling(GridEditCancelingEventArgs e)
|
|
||||||
{
|
|
||||||
GridEditState = MgGridEditState.None;
|
|
||||||
|
|
||||||
InfoPanelInstance?.ClearEditMode();
|
|
||||||
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task SaveChangesToServerAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return _dataSource.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Error($"{_gridLogName} SaveChangesToServerAsync->SaveChangesAsync error!", ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> SaveChangesToServer()
|
|
||||||
{
|
|
||||||
var result = false;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var unsavedItems = await _dataSource.SaveChanges();
|
|
||||||
|
|
||||||
if (!(result = unsavedItems.Count == 0))
|
|
||||||
Logger.Error($"{_gridLogName} SaveChangesToServer->SaveChanges error! unsavedCount: {unsavedItems.Count}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Error($"{_gridLogName} OnItemSaving", ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnItemDeleting(GridDataItemDeletingEventArgs e)
|
|
||||||
{
|
|
||||||
Logger.Debug($"{_gridLogName} OnItemDeleting");
|
|
||||||
|
|
||||||
await OnGridItemDeleting.InvokeAsync(e);
|
|
||||||
|
|
||||||
if (e.Cancel)
|
|
||||||
{
|
|
||||||
Logger.Debug($"{_gridLogName} OnItemDeleting canceled");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var dataItem = (e.DataItem as TDataItem)!;
|
|
||||||
await RemoveDataItem(dataItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnCustomizeElement(GridCustomizeElementEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.ElementType == GridElementType.DetailCell)
|
|
||||||
{
|
|
||||||
e.Style = "padding: 0.5rem; opacity: 0.75";
|
|
||||||
}
|
|
||||||
else if (false && e.ElementType == GridElementType.DataCell && e.Column.Name == nameof(IId<TId>.Id))
|
|
||||||
{
|
|
||||||
e.Column.Visible = AcDomain.IsDeveloperVersion;
|
|
||||||
e.Column.ShowInColumnChooser = AcDomain.IsDeveloperVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply edit mode background to the row being edited
|
|
||||||
if (e.ElementType == GridElementType.DataRow && GridEditState != MgGridEditState.None)
|
|
||||||
{
|
|
||||||
if (e.VisibleIndex == GetFocusedRowIndex())
|
|
||||||
{
|
|
||||||
e.Style = string.IsNullOrEmpty(e.Style)
|
|
||||||
? "background-color: #fffbeb;"
|
|
||||||
: e.Style + " background-color: #fffbeb;";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Apply edit mode background to cells in the edited row
|
|
||||||
else if (e.ElementType == GridElementType.DataCell && GridEditState != MgGridEditState.None)
|
|
||||||
{
|
|
||||||
if (e.VisibleIndex == GetFocusedRowIndex())
|
|
||||||
{
|
|
||||||
e.Style = string.IsNullOrEmpty(e.Style)
|
|
||||||
? "background-color: #fffbeb;"
|
|
||||||
: e.Style + " background-color: #fffbeb;";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task SetParametersAsyncCore(ParameterView parameters)
|
|
||||||
{
|
|
||||||
await base.SetParametersAsyncCore(parameters);
|
|
||||||
|
|
||||||
if (!IsFirstInitializeParameterCore)
|
|
||||||
{
|
|
||||||
//if (typeof(TDataItem) is IId<TId> || typeof(TDataItem) is IId<TId>)
|
|
||||||
KeyFieldName = "Id";
|
|
||||||
|
|
||||||
base.DataItemDeleting = EventCallback.Factory.Create<GridDataItemDeletingEventArgs>(this, OnItemDeleting);
|
|
||||||
base.EditModelSaving = EventCallback.Factory.Create<GridEditModelSavingEventArgs>(this, OnItemSaving);
|
|
||||||
base.CustomizeEditModel = EventCallback.Factory.Create<GridCustomizeEditModelEventArgs>(this, OnCustomizeEditModel);
|
|
||||||
base.FocusedRowChanged = EventCallback.Factory.Create<GridFocusedRowChangedEventArgs>(this, OnFocusedRowChanged);
|
|
||||||
base.EditStart = EventCallback.Factory.Create<GridEditStartEventArgs>(this, OnEditStart);
|
|
||||||
base.EditCanceling = EventCallback.Factory.Create<GridEditCancelingEventArgs>(this, OnEditCanceling);
|
|
||||||
|
|
||||||
CustomizeElement += OnCustomizeElement;
|
|
||||||
|
|
||||||
//ShowFilterRow = true;
|
|
||||||
//PageSize = 4;
|
|
||||||
//ShowGroupPanel = true;
|
|
||||||
//AllowSort = false;
|
|
||||||
|
|
||||||
TextWrapEnabled = false;
|
|
||||||
AllowSelectRowByClick = true;
|
|
||||||
HighlightRowOnHover = true;
|
|
||||||
AutoCollapseDetailRow = true;
|
|
||||||
AutoExpandAllGroupRows = false;
|
|
||||||
|
|
||||||
IsFirstInitializeParameterCore = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
|
||||||
{
|
|
||||||
if (!IsFirstInitializeParameters)
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Register this grid with the wrapper for splitter size persistence
|
|
||||||
GridWrapper?.RegisterGrid(this);
|
|
||||||
|
|
||||||
IsFirstInitializeParameters = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stores the default layout (before any saved layout is loaded) for reset functionality
|
|
||||||
/// </summary>
|
|
||||||
private string? _defaultLayoutJson = null;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a layout exists in localStorage without loading its content
|
|
||||||
/// </summary>
|
|
||||||
protected virtual async Task<string?> GetStorageItem(string localStorageKey)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await JSRuntime.InvokeAsync<string>("localStorage.getItem", localStorageKey);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Mute exceptions for the server prerender stage
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task Grid_LayoutAutoLoading(GridPersistentLayoutEventArgs e)
|
|
||||||
{
|
|
||||||
// Save the default layout before loading any saved layout
|
|
||||||
_defaultLayoutJson ??= JsonSerializer.Serialize(SaveLayout());
|
|
||||||
|
|
||||||
e.Layout = await LoadLayoutFromLocalStorageAsync(AutomaticLayoutStorageKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task Grid_LayoutAutoSaving(GridPersistentLayoutEventArgs e)
|
|
||||||
{
|
|
||||||
await SaveLayoutToLocalStorageAsync(e.Layout, AutomaticLayoutStorageKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected virtual async Task<GridPersistentLayout?> LoadLayoutFromLocalStorageAsync(string localStorageKey)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var json = await GetStorageItem(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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected virtual async Task RemoveLayoutFromLocalStorageAsync(string localStorageKey)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await JSRuntime.InvokeVoidAsync("localStorage.removeItem", localStorageKey);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Mute exceptions for the server prerender stage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task SaveUserLayoutAsync()
|
|
||||||
{
|
|
||||||
var layout = SaveLayout();
|
|
||||||
|
|
||||||
await SaveLayoutToLocalStorageAsync(layout, UserLayoutStorageKey);
|
|
||||||
await SaveLayoutToLocalStorageAsync(layout, AutomaticLayoutStorageKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task LoadUserLayoutAsync()
|
|
||||||
{
|
|
||||||
var layout = await LoadLayoutFromLocalStorageAsync(UserLayoutStorageKey);
|
|
||||||
if (layout != null)
|
|
||||||
{
|
|
||||||
LoadLayout(layout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task ResetLayoutAsync()
|
|
||||||
{
|
|
||||||
await RemoveLayoutFromLocalStorageAsync(AutomaticLayoutStorageKey);
|
|
||||||
|
|
||||||
// Restore the default layout if available
|
|
||||||
if (!string.IsNullOrWhiteSpace(_defaultLayoutJson))
|
|
||||||
{
|
|
||||||
var defaultLayout = JsonSerializer.Deserialize<GridPersistentLayout>(_defaultLayoutJson);
|
|
||||||
if (defaultLayout != null)
|
|
||||||
LoadLayout(defaultLayout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<bool> HasUserLayoutAsync()
|
|
||||||
{
|
|
||||||
return !(await GetStorageItem(UserLayoutStorageKey)).IsNullOrWhiteSpace();
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
//public Task AddDataItem(TDataItem dataItem, int messageTag) => PostDataToServerAsync(dataItem, messageTag, TrackingState.Add);
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Force grid re-initialization
|
|
||||||
/// </summary>
|
|
||||||
/// <returns></returns>
|
|
||||||
public async Task ForceRenderAsync()
|
|
||||||
{
|
|
||||||
// Force grid re-initialization by changing the render key
|
|
||||||
_gridRenderKey = Guid.NewGuid();
|
|
||||||
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task UpdateDataItem(TDataItem dataItem) => _dataSource.Update(dataItem, true);
|
|
||||||
|
|
||||||
public Task UpdateDataItemAsync(TDataItem dataItem)
|
|
||||||
{
|
|
||||||
_dataSource.Update(dataItem, false);
|
|
||||||
return SaveChangesToServerAsync();
|
|
||||||
}
|
|
||||||
//public Task UpdateDataItem(TDataItem dataItem, int messageTag) => PostDataToServerAsync(dataItem, messageTag, TrackingState.Update);
|
|
||||||
|
|
||||||
public Task AddOrUpdateDataItem(TDataItem dataItem) => _dataSource.AddOrUpdate(dataItem, true);
|
|
||||||
|
|
||||||
public Task RemoveDataItem(TDataItem dataItem) => _dataSource.Remove(dataItem, true);
|
|
||||||
//public Task RemoveDataItem(TDataItem dataItem, int messageTag) => PostDataToServerAsync(dataItem, messageTag, TrackingState.Remove);
|
|
||||||
|
|
||||||
public Task RemoveDataItem(TId id) => RemoveDataItem(id, RemoveMessageTag);
|
|
||||||
|
|
||||||
public Task RemoveDataItem(TId id, int messageTag)
|
|
||||||
{
|
|
||||||
return _dataSource.Remove(id, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task ReloadDataSourceAsync()
|
|
||||||
{
|
|
||||||
return _dataSource.LoadDataSourceAsync(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Navigates to the previous row in the grid
|
|
||||||
/// </summary>
|
|
||||||
public void StepPrevRow()
|
|
||||||
{
|
|
||||||
var currentIndex = GetFocusedRowIndex();
|
|
||||||
if (currentIndex > 0)
|
|
||||||
{
|
|
||||||
SetFocusedRowIndex(currentIndex - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Navigates to the next row in the grid
|
|
||||||
/// </summary>
|
|
||||||
public void StepNextRow()
|
|
||||||
{
|
|
||||||
var currentIndex = GetFocusedRowIndex();
|
|
||||||
var visibleRowCount = GetVisibleRowCount();
|
|
||||||
if (currentIndex >= 0 && currentIndex < visibleRowCount - 1)
|
|
||||||
{
|
|
||||||
SetFocusedRowIndex(currentIndex + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
if (_isDisposed) return;
|
|
||||||
_isDisposed = true;
|
|
||||||
|
|
||||||
// Unsubscribe from events to prevent callbacks to disposed component
|
|
||||||
if (_dataSource != null)
|
|
||||||
{
|
|
||||||
_dataSource.OnDataSourceLoaded -= OnDataSourceLoaded;
|
|
||||||
_dataSource.OnDataSourceItemChanged -= OnDataSourceItemChanged;
|
|
||||||
_dataSource.OnSyncingStateChanged -= OnDataSourceSyncingStateChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
CustomizeElement -= OnCustomizeElement;
|
|
||||||
|
|
||||||
// Dispose base if it implements IAsyncDisposable
|
|
||||||
if (this is IAsyncDisposable asyncDisposable && asyncDisposable != this)
|
|
||||||
{
|
|
||||||
await asyncDisposable.DisposeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class GridDataItemChangingEventArgs<TDataItem> : GridDataItemChangedEventArgs<TDataItem> where TDataItem : class
|
|
||||||
{
|
|
||||||
internal GridDataItemChangingEventArgs(IMgGridBase grid, TDataItem dataItem, TrackingState trackingState) : base(grid, dataItem, trackingState)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsCanceled { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class GridDataItemChangedEventArgs<TDataItem> where TDataItem : class
|
|
||||||
{
|
|
||||||
internal GridDataItemChangedEventArgs(IMgGridBase grid, TDataItem dataItem, TrackingState trackingState)
|
|
||||||
{
|
|
||||||
Grid = grid ?? throw new ArgumentNullException(nameof(grid));
|
|
||||||
DataItem = dataItem;
|
|
||||||
TrackingState = trackingState;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IMgGridBase Grid { get; }
|
|
||||||
public TDataItem DataItem { get; }
|
|
||||||
public TrackingState TrackingState { get; }
|
|
||||||
public bool CancelStateChangeInvoke { get; set; }
|
|
||||||
}
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
using DevExpress.Blazor;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Linq.Expressions;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace AyCode.Blazor.Components.Components.Grids;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extended DxGridDataColumn with additional parameters for InfoPanel support.
|
|
||||||
/// </summary>
|
|
||||||
public partial class MgGridDataColumn : DxGridDataColumn
|
|
||||||
{
|
|
||||||
private static readonly ConcurrentDictionary<(Type Type, string Property), Func<object, object?>?> SAccessorCache = new();
|
|
||||||
|
|
||||||
private string? _urlLink;
|
|
||||||
private bool _isInitialized;
|
|
||||||
private TemplatePart[]? _templateParts;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether this column should be visible in the InfoPanel. Default is true.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public bool ShowInInfoPanel { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Custom display format for InfoPanel (overrides DisplayFormat if set).
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public string? InfoPanelDisplayFormat { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Column order in InfoPanel (lower = earlier). Default is int.MaxValue.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public int InfoPanelOrder { get; set; } = int.MaxValue;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// URL template with {property} placeholders that will be replaced with row values.
|
|
||||||
/// Example: https://shop.fruitbank.hu/Admin/Order/Edit/{Id}/{OrderId}
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public string? UrlLink
|
|
||||||
{
|
|
||||||
get => _urlLink;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (_urlLink == value) return;
|
|
||||||
|
|
||||||
_urlLink = value;
|
|
||||||
if (_isInitialized) UpdateCellDisplayTemplate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
|
||||||
{
|
|
||||||
base.OnParametersSet();
|
|
||||||
_isInitialized = true;
|
|
||||||
UpdateCellDisplayTemplate();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateCellDisplayTemplate()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(_urlLink)) return;
|
|
||||||
|
|
||||||
_templateParts = ParseTemplate(_urlLink);
|
|
||||||
var parts = _templateParts;
|
|
||||||
|
|
||||||
CellDisplayTemplate = context => builder =>
|
|
||||||
{
|
|
||||||
var url = BuildUrl(parts, context.DataItem);
|
|
||||||
builder.OpenElement(0, "a");
|
|
||||||
builder.AddAttribute(1, "href", url);
|
|
||||||
builder.AddAttribute(2, "target", "_blank");
|
|
||||||
builder.AddAttribute(3, "style", "text-decoration: underline; color: inherit;");
|
|
||||||
builder.AddContent(4, context.DisplayText);
|
|
||||||
builder.CloseElement();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a parsed segment of a URL template: either a literal string or a property placeholder.
|
|
||||||
/// </summary>
|
|
||||||
internal readonly record struct TemplatePart(string Value, bool IsProperty);
|
|
||||||
|
|
||||||
[GeneratedRegex(@"\{([^}]+)\}")]
|
|
||||||
private static partial Regex TemplateRegex();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses a URL template into literal and property placeholder segments.
|
|
||||||
/// </summary>
|
|
||||||
internal static TemplatePart[] ParseTemplate(string template)
|
|
||||||
{
|
|
||||||
var parts = new List<TemplatePart>();
|
|
||||||
var lastIndex = 0;
|
|
||||||
|
|
||||||
foreach (Match match in TemplateRegex().Matches(template))
|
|
||||||
{
|
|
||||||
if (match.Index > lastIndex) parts.Add(new TemplatePart(template[lastIndex..match.Index], IsProperty: false));
|
|
||||||
|
|
||||||
parts.Add(new TemplatePart(match.Groups[1].Value, IsProperty: true));
|
|
||||||
lastIndex = match.Index + match.Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < template.Length) parts.Add(new TemplatePart(template[lastIndex..], IsProperty: false));
|
|
||||||
|
|
||||||
return [.. parts];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds a URL from pre-parsed template parts using cached compiled property accessors.
|
|
||||||
/// </summary>
|
|
||||||
internal static string BuildUrl(TemplatePart[] parts, object? dataItem)
|
|
||||||
{
|
|
||||||
if (dataItem is null || parts.Length == 0)
|
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
var type = dataItem.GetType();
|
|
||||||
var sb = new StringBuilder(parts.Length * 16);
|
|
||||||
|
|
||||||
foreach (var part in parts)
|
|
||||||
{
|
|
||||||
if (!part.IsProperty)
|
|
||||||
{
|
|
||||||
sb.Append(part.Value);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var accessor = SAccessorCache.GetOrAdd((type, part.Value), static key => CompileAccessor(key.Type, key.Property));
|
|
||||||
|
|
||||||
if (accessor is not null) sb.Append(accessor(dataItem)?.ToString() ?? string.Empty);
|
|
||||||
else sb.Append('{').Append(part.Value).Append('}');
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Replaces {property} placeholders in the template with values from the data item.
|
|
||||||
/// Convenience overload that parses the template on each call — prefer pre-parsed <see cref="BuildUrl"/> for hot paths.
|
|
||||||
/// </summary>
|
|
||||||
internal static string BuildUrlFromTemplate(string template, object? dataItem)
|
|
||||||
{
|
|
||||||
return dataItem is null ? template : BuildUrl(ParseTemplate(template), dataItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Func<object, object?>? CompileAccessor(Type type, string propertyName)
|
|
||||||
{
|
|
||||||
var prop = type.GetProperty(propertyName);
|
|
||||||
if (prop is null) return null;
|
|
||||||
|
|
||||||
var param = Expression.Parameter(typeof(object), "obj");
|
|
||||||
var body = Expression.Convert(
|
|
||||||
Expression.Property(Expression.Convert(param, type), prop),
|
|
||||||
typeof(object));
|
|
||||||
|
|
||||||
return Expression.Lambda<Func<object, object?>>(body, param).Compile();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace AyCode.Blazor.Components.Components.Grids
|
|
||||||
{
|
|
||||||
internal class MgGridHelper
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,401 +0,0 @@
|
||||||
@using DevExpress.Blazor
|
|
||||||
@using Microsoft.AspNetCore.Components.Rendering
|
|
||||||
@using System.Reflection
|
|
||||||
|
|
||||||
<div @ref="_panelElement" class="mg-grid-info-panel @(_isEditMode ? "edit-mode" : "") @GetColumnCountClass()">
|
|
||||||
@* Header *@
|
|
||||||
@if (HeaderTemplate != null)
|
|
||||||
{
|
|
||||||
@HeaderTemplate(CreateContext())
|
|
||||||
}
|
|
||||||
else if (_currentGrid != null)
|
|
||||||
{
|
|
||||||
<div class="mg-info-panel-header">@_currentGrid.Caption</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@* Toolbar *@
|
|
||||||
@if (_currentGrid != null)
|
|
||||||
{
|
|
||||||
<div class="mg-info-panel-toolbar">
|
|
||||||
<MgGridToolbarTemplate Grid="_currentGrid" OnlyGridEditTools="true" ShowOnlyIcon="true" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@* Content *@
|
|
||||||
<div class="mg-info-panel-content">
|
|
||||||
@if (GetActiveDataItem() != null && _currentGrid != null)
|
|
||||||
{
|
|
||||||
@if (BeforeColumnsTemplate != null)
|
|
||||||
{
|
|
||||||
@BeforeColumnsTemplate(CreateContext())
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (ColumnsTemplate != null)
|
|
||||||
{
|
|
||||||
@ColumnsTemplate(CreateContext())
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@RenderDefaultColumns()
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (AfterColumnsTemplate != null)
|
|
||||||
{
|
|
||||||
@AfterColumnsTemplate(CreateContext())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="mg-info-panel-empty">
|
|
||||||
<p>Válasszon ki egy sort az adatok megtekintéséhez</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@* Footer *@
|
|
||||||
@if (FooterTemplate != null)
|
|
||||||
{
|
|
||||||
@FooterTemplate(CreateContext())
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public RenderFragment<InfoPanelContext>? HeaderTemplate { get; set; }
|
|
||||||
[Parameter] public RenderFragment<InfoPanelContext>? BeforeColumnsTemplate { get; set; }
|
|
||||||
[Parameter] public RenderFragment<InfoPanelContext>? ColumnsTemplate { get; set; }
|
|
||||||
[Parameter] public RenderFragment<InfoPanelContext>? AfterColumnsTemplate { get; set; }
|
|
||||||
[Parameter] public RenderFragment<InfoPanelContext>? FooterTemplate { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Called when the data item changes (row selection changed)
|
|
||||||
/// </summary>
|
|
||||||
[Parameter] public EventCallback<object?> OnDataItemChanged { get; set; }
|
|
||||||
|
|
||||||
private InfoPanelContext CreateContext() => new(GetActiveDataItem(), _isEditMode);
|
|
||||||
|
|
||||||
private string GetColumnCountClass() => FixedColumnCount switch
|
|
||||||
{
|
|
||||||
1 => "mg-columns-1",
|
|
||||||
2 => "mg-columns-2",
|
|
||||||
3 => "mg-columns-3",
|
|
||||||
4 => "mg-columns-4",
|
|
||||||
_ => ""
|
|
||||||
};
|
|
||||||
|
|
||||||
private RenderFragment RenderDefaultColumns() => builder =>
|
|
||||||
{
|
|
||||||
var dataItem = GetActiveDataItem();
|
|
||||||
if (dataItem == null) return;
|
|
||||||
|
|
||||||
var dataItemType = dataItem.GetType();
|
|
||||||
var seq = 0;
|
|
||||||
|
|
||||||
builder.OpenElement(seq++, "div");
|
|
||||||
builder.AddAttribute(seq++, "class", "mg-info-panel-grid");
|
|
||||||
|
|
||||||
foreach (var column in GetVisibleColumns())
|
|
||||||
{
|
|
||||||
var displayText = GetDisplayTextFromGrid(column);
|
|
||||||
var value = GetCellValue(column);
|
|
||||||
var settingsType = GetEditSettingsType(column);
|
|
||||||
var isEditable = _isEditMode && !column.ReadOnly;
|
|
||||||
|
|
||||||
builder.OpenElement(seq++, "div");
|
|
||||||
builder.AddAttribute(seq++, "class", "mg-info-panel-item");
|
|
||||||
|
|
||||||
builder.OpenElement(seq++, "label");
|
|
||||||
builder.AddAttribute(seq++, "class", isEditable ? "mg-info-panel-label editable" : "mg-info-panel-label");
|
|
||||||
builder.AddContent(seq++, GetColumnCaption(column));
|
|
||||||
builder.CloseElement();
|
|
||||||
|
|
||||||
builder.OpenElement(seq++, "div");
|
|
||||||
if (isEditable)
|
|
||||||
{
|
|
||||||
RenderEditableCell(column, dataItem, dataItemType, value, displayText, settingsType)(builder);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
RenderCellContent(value, displayText, column, dataItem)(builder);
|
|
||||||
}
|
|
||||||
builder.CloseElement();
|
|
||||||
|
|
||||||
builder.CloseElement();
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.CloseElement();
|
|
||||||
};
|
|
||||||
|
|
||||||
private static string GetColumnCaption(DxGridDataColumn column) =>
|
|
||||||
!string.IsNullOrWhiteSpace(column.Caption) ? column.Caption : column.FieldName;
|
|
||||||
|
|
||||||
private RenderFragment RenderEditableCell(DxGridDataColumn column, object dataItem, Type dataItemType, object? value, string displayText, EditSettingsType settingsType)
|
|
||||||
{
|
|
||||||
return builder =>
|
|
||||||
{
|
|
||||||
var seq = 0;
|
|
||||||
var propertyInfo = dataItemType.GetProperty(column.FieldName);
|
|
||||||
|
|
||||||
if (propertyInfo == null)
|
|
||||||
{
|
|
||||||
RenderCellContent(value, displayText)(builder);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var underlyingType = Nullable.GetUnderlyingType(propertyInfo.PropertyType) ?? propertyInfo.PropertyType;
|
|
||||||
|
|
||||||
if (settingsType == EditSettingsType.ComboBox && GetEditSettingsCached(dataItemType, column.FieldName) is DxComboBoxSettings comboSettings)
|
|
||||||
{
|
|
||||||
RenderComboBoxEditor(builder, ref seq, dataItem, propertyInfo, comboSettings);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (underlyingType == typeof(bool)) RenderCheckBoxEditor(builder, ref seq, dataItem, propertyInfo);
|
|
||||||
else if (underlyingType == typeof(DateTime)) RenderDateTimeEditor(builder, ref seq, dataItem, propertyInfo, column.DisplayFormat);
|
|
||||||
else if (underlyingType == typeof(DateOnly)) RenderDateOnlyEditor(builder, ref seq, dataItem, propertyInfo, column.DisplayFormat);
|
|
||||||
else if (underlyingType == typeof(int)) RenderSpinIntEditor(builder, ref seq, dataItem, propertyInfo);
|
|
||||||
else if (underlyingType == typeof(decimal)) RenderSpinDecimalEditor(builder, ref seq, dataItem, propertyInfo);
|
|
||||||
else if (underlyingType == typeof(double)) RenderSpinDoubleEditor(builder, ref seq, dataItem, propertyInfo);
|
|
||||||
else if (settingsType == EditSettingsType.Memo) RenderMemoEditor(builder, ref seq, dataItem, propertyInfo);
|
|
||||||
else RenderTextBoxEditor(builder, ref seq, dataItem, propertyInfo);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RenderCheckBoxEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo)
|
|
||||||
{
|
|
||||||
builder.OpenComponent<DxCheckBox<bool>>(seq++);
|
|
||||||
builder.AddAttribute(seq++, "Checked", (bool)(propertyInfo.GetValue(dataItem) ?? false));
|
|
||||||
builder.AddAttribute(seq++, "CheckedChanged", EventCallback.Factory.Create<bool>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
|
|
||||||
builder.CloseComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RenderDateTimeEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo, string? displayFormat)
|
|
||||||
{
|
|
||||||
var isNullable = Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null;
|
|
||||||
var value = propertyInfo.GetValue(dataItem);
|
|
||||||
|
|
||||||
if (isNullable)
|
|
||||||
{
|
|
||||||
builder.OpenComponent<DxDateEdit<DateTime?>>(seq++);
|
|
||||||
builder.AddAttribute(seq++, "Date", (DateTime?)value);
|
|
||||||
builder.AddAttribute(seq++, "DateChanged", EventCallback.Factory.Create<DateTime?>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
builder.OpenComponent<DxDateEdit<DateTime>>(seq++);
|
|
||||||
builder.AddAttribute(seq++, "Date", (DateTime)(value ?? DateTime.MinValue));
|
|
||||||
builder.AddAttribute(seq++, "DateChanged", EventCallback.Factory.Create<DateTime>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
|
|
||||||
}
|
|
||||||
builder.AddAttribute(seq++, "DisplayFormat", displayFormat ?? "yyyy-MM-dd HH:mm");
|
|
||||||
builder.CloseComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RenderDateOnlyEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo, string? displayFormat)
|
|
||||||
{
|
|
||||||
var isNullable = Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null;
|
|
||||||
var value = propertyInfo.GetValue(dataItem);
|
|
||||||
|
|
||||||
if (isNullable)
|
|
||||||
{
|
|
||||||
builder.OpenComponent<DxDateEdit<DateOnly?>>(seq++);
|
|
||||||
builder.AddAttribute(seq++, "Date", (DateOnly?)value);
|
|
||||||
builder.AddAttribute(seq++, "DateChanged", EventCallback.Factory.Create<DateOnly?>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
builder.OpenComponent<DxDateEdit<DateOnly>>(seq++);
|
|
||||||
builder.AddAttribute(seq++, "Date", (DateOnly)(value ?? DateOnly.MinValue));
|
|
||||||
builder.AddAttribute(seq++, "DateChanged", EventCallback.Factory.Create<DateOnly>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
|
|
||||||
}
|
|
||||||
builder.AddAttribute(seq++, "DisplayFormat", displayFormat ?? "yyyy-MM-dd");
|
|
||||||
builder.CloseComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RenderSpinIntEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo)
|
|
||||||
{
|
|
||||||
var isNullable = Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null;
|
|
||||||
var value = propertyInfo.GetValue(dataItem);
|
|
||||||
|
|
||||||
if (isNullable)
|
|
||||||
{
|
|
||||||
builder.OpenComponent<DxSpinEdit<int?>>(seq++);
|
|
||||||
builder.AddAttribute(seq++, "Value", (int?)value);
|
|
||||||
builder.AddAttribute(seq++, "ValueChanged", EventCallback.Factory.Create<int?>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
builder.OpenComponent<DxSpinEdit<int>>(seq++);
|
|
||||||
builder.AddAttribute(seq++, "Value", (int)(value ?? 0));
|
|
||||||
builder.AddAttribute(seq++, "ValueChanged", EventCallback.Factory.Create<int>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
|
|
||||||
}
|
|
||||||
builder.CloseComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RenderSpinDecimalEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo)
|
|
||||||
{
|
|
||||||
var isNullable = Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null;
|
|
||||||
var value = propertyInfo.GetValue(dataItem);
|
|
||||||
|
|
||||||
if (isNullable)
|
|
||||||
{
|
|
||||||
builder.OpenComponent<DxSpinEdit<decimal?>>(seq++);
|
|
||||||
builder.AddAttribute(seq++, "Value", (decimal?)value);
|
|
||||||
builder.AddAttribute(seq++, "ValueChanged", EventCallback.Factory.Create<decimal?>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
builder.OpenComponent<DxSpinEdit<decimal>>(seq++);
|
|
||||||
builder.AddAttribute(seq++, "Value", (decimal)(value ?? 0m));
|
|
||||||
builder.AddAttribute(seq++, "ValueChanged", EventCallback.Factory.Create<decimal>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
|
|
||||||
}
|
|
||||||
builder.CloseComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RenderSpinDoubleEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo)
|
|
||||||
{
|
|
||||||
var isNullable = Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null;
|
|
||||||
var value = propertyInfo.GetValue(dataItem);
|
|
||||||
|
|
||||||
if (isNullable)
|
|
||||||
{
|
|
||||||
builder.OpenComponent<DxSpinEdit<double?>>(seq++);
|
|
||||||
builder.AddAttribute(seq++, "Value", (double?)value);
|
|
||||||
builder.AddAttribute(seq++, "ValueChanged", EventCallback.Factory.Create<double?>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
builder.OpenComponent<DxSpinEdit<double>>(seq++);
|
|
||||||
builder.AddAttribute(seq++, "Value", (double)(value ?? 0d));
|
|
||||||
builder.AddAttribute(seq++, "ValueChanged", EventCallback.Factory.Create<double>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
|
|
||||||
}
|
|
||||||
builder.CloseComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RenderTextBoxEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo)
|
|
||||||
{
|
|
||||||
builder.OpenComponent<DxTextBox>(seq++);
|
|
||||||
builder.AddAttribute(seq++, "Text", propertyInfo.GetValue(dataItem)?.ToString() ?? "");
|
|
||||||
builder.AddAttribute(seq++, "TextChanged", EventCallback.Factory.Create<string>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
|
|
||||||
builder.CloseComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RenderMemoEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo)
|
|
||||||
{
|
|
||||||
builder.OpenComponent<DxMemo>(seq++);
|
|
||||||
builder.AddAttribute(seq++, "Text", propertyInfo.GetValue(dataItem)?.ToString() ?? "");
|
|
||||||
builder.AddAttribute(seq++, "TextChanged", EventCallback.Factory.Create<string>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
|
|
||||||
builder.AddAttribute(seq++, "Rows", 3);
|
|
||||||
builder.CloseComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RenderComboBoxEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo, DxComboBoxSettings settings)
|
|
||||||
{
|
|
||||||
var value = propertyInfo.GetValue(dataItem);
|
|
||||||
var underlyingType = Nullable.GetUnderlyingType(propertyInfo.PropertyType) ?? propertyInfo.PropertyType;
|
|
||||||
var itemType = settings.Data?.GetType().GetGenericArguments().FirstOrDefault() ?? typeof(object);
|
|
||||||
|
|
||||||
if (underlyingType == typeof(int))
|
|
||||||
RenderComboBoxInt(builder, ref seq, dataItem, propertyInfo, settings, itemType, value);
|
|
||||||
else if (underlyingType == typeof(long))
|
|
||||||
RenderComboBoxLong(builder, ref seq, dataItem, propertyInfo, settings, itemType, value);
|
|
||||||
else if (underlyingType == typeof(Guid))
|
|
||||||
RenderComboBoxGuid(builder, ref seq, dataItem, propertyInfo, settings, itemType, value);
|
|
||||||
else
|
|
||||||
{
|
|
||||||
builder.OpenComponent<DxTextBox>(seq++);
|
|
||||||
builder.AddAttribute(seq++, "Text", ResolveComboBoxDisplayText(settings, value ?? new object()) ?? value?.ToString() ?? "");
|
|
||||||
builder.AddAttribute(seq++, "ReadOnly", true);
|
|
||||||
builder.CloseComponent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RenderComboBoxInt(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo, DxComboBoxSettings settings, Type itemType, object? currentValue)
|
|
||||||
{
|
|
||||||
var isNullable = Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null;
|
|
||||||
var comboType = isNullable ? typeof(DxComboBox<,>).MakeGenericType(itemType, typeof(int?)) : typeof(DxComboBox<,>).MakeGenericType(itemType, typeof(int));
|
|
||||||
|
|
||||||
builder.OpenComponent(seq++, comboType);
|
|
||||||
builder.AddAttribute(seq++, "Data", settings.Data);
|
|
||||||
builder.AddAttribute(seq++, "ValueFieldName", settings.ValueFieldName);
|
|
||||||
builder.AddAttribute(seq++, "TextFieldName", settings.TextFieldName);
|
|
||||||
builder.AddAttribute(seq++, "Value", isNullable ? currentValue as int? : (currentValue is int intVal ? intVal : 0));
|
|
||||||
builder.AddAttribute(seq++, "ValueChanged", isNullable
|
|
||||||
? EventCallback.Factory.Create<int?>(this, v => { propertyInfo.SetValue(dataItem, v); StateHasChanged(); })
|
|
||||||
: EventCallback.Factory.Create<int>(this, v => { propertyInfo.SetValue(dataItem, v); StateHasChanged(); }));
|
|
||||||
builder.AddAttribute(seq++, "ClearButtonDisplayMode", DataEditorClearButtonDisplayMode.Auto);
|
|
||||||
builder.CloseComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RenderComboBoxLong(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo, DxComboBoxSettings settings, Type itemType, object? currentValue)
|
|
||||||
{
|
|
||||||
var isNullable = Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null;
|
|
||||||
var comboType = isNullable ? typeof(DxComboBox<,>).MakeGenericType(itemType, typeof(long?)) : typeof(DxComboBox<,>).MakeGenericType(itemType, typeof(long));
|
|
||||||
|
|
||||||
builder.OpenComponent(seq++, comboType);
|
|
||||||
builder.AddAttribute(seq++, "Data", settings.Data);
|
|
||||||
builder.AddAttribute(seq++, "ValueFieldName", settings.ValueFieldName);
|
|
||||||
builder.AddAttribute(seq++, "TextFieldName", settings.TextFieldName);
|
|
||||||
builder.AddAttribute(seq++, "Value", isNullable ? currentValue as long? : (currentValue is long longVal ? longVal : 0L));
|
|
||||||
builder.AddAttribute(seq++, "ValueChanged", isNullable
|
|
||||||
? EventCallback.Factory.Create<long?>(this, v => { propertyInfo.SetValue(dataItem, v); StateHasChanged(); })
|
|
||||||
: EventCallback.Factory.Create<long>(this, v => { propertyInfo.SetValue(dataItem, v); StateHasChanged(); }));
|
|
||||||
builder.AddAttribute(seq++, "ClearButtonDisplayMode", DataEditorClearButtonDisplayMode.Auto);
|
|
||||||
builder.CloseComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RenderComboBoxGuid(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo, DxComboBoxSettings settings, Type itemType, object? currentValue)
|
|
||||||
{
|
|
||||||
var isNullable = Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null;
|
|
||||||
var comboType = isNullable ? typeof(DxComboBox<,>).MakeGenericType(itemType, typeof(Guid?)) : typeof(DxComboBox<,>).MakeGenericType(itemType, typeof(Guid));
|
|
||||||
|
|
||||||
builder.OpenComponent(seq++, comboType);
|
|
||||||
builder.AddAttribute(seq++, "Data", settings.Data);
|
|
||||||
builder.AddAttribute(seq++, "ValueFieldName", settings.ValueFieldName);
|
|
||||||
builder.AddAttribute(seq++, "TextFieldName", settings.TextFieldName);
|
|
||||||
builder.AddAttribute(seq++, "Value", isNullable ? currentValue as Guid? : (currentValue is Guid guidVal ? guidVal : Guid.Empty));
|
|
||||||
builder.AddAttribute(seq++, "ValueChanged", isNullable
|
|
||||||
? EventCallback.Factory.Create<Guid?>(this, v => { propertyInfo.SetValue(dataItem, v); StateHasChanged(); })
|
|
||||||
: EventCallback.Factory.Create<Guid>(this, v => { propertyInfo.SetValue(dataItem, v); StateHasChanged(); }));
|
|
||||||
builder.AddAttribute(seq++, "ClearButtonDisplayMode", DataEditorClearButtonDisplayMode.Auto);
|
|
||||||
builder.CloseComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private RenderFragment RenderCellContent(object? value, string displayText, DxGridDataColumn? column = null, object? dataItem = null)
|
|
||||||
{
|
|
||||||
return builder =>
|
|
||||||
{
|
|
||||||
var seq = 0;
|
|
||||||
|
|
||||||
// Check if column has UrlLink
|
|
||||||
if (column is MgGridDataColumn mgColumn && !string.IsNullOrWhiteSpace(mgColumn.UrlLink) && dataItem != null)
|
|
||||||
{
|
|
||||||
var url = MgGridDataColumn.BuildUrlFromTemplate(mgColumn.UrlLink, dataItem);
|
|
||||||
|
|
||||||
builder.OpenElement(seq++, "a");
|
|
||||||
builder.AddAttribute(seq++, "href", url);
|
|
||||||
builder.AddAttribute(seq++, "target", "_blank");
|
|
||||||
builder.AddAttribute(seq++, "class", "mg-info-panel-link");
|
|
||||||
builder.AddAttribute(seq++, "title", displayText);
|
|
||||||
builder.AddContent(seq++, displayText);
|
|
||||||
builder.CloseElement();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.OpenElement(seq++, "span");
|
|
||||||
builder.AddAttribute(seq++, "class", "mg-info-panel-value");
|
|
||||||
builder.AddAttribute(seq++, "title", displayText);
|
|
||||||
|
|
||||||
if (value is bool boolValue)
|
|
||||||
{
|
|
||||||
builder.OpenElement(seq++, "span");
|
|
||||||
builder.AddAttribute(seq++, "class", boolValue ? "dx-icon dx-icon-check" : "dx-icon dx-icon-close");
|
|
||||||
builder.CloseElement();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
builder.AddContent(seq++, displayText);
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.CloseElement();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,488 +0,0 @@
|
||||||
using DevExpress.Blazor;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
using Microsoft.JSInterop;
|
|
||||||
|
|
||||||
namespace AyCode.Blazor.Components.Components.Grids;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Interface for InfoPanel to support grid access
|
|
||||||
/// </summary>
|
|
||||||
public interface IInfoPanelBase
|
|
||||||
{
|
|
||||||
void ClearEditMode();
|
|
||||||
void SetEditMode(IMgGridBase grid, object editModel);
|
|
||||||
void RefreshData(IMgGridBase grid, object? dataItem, int visibleIndex = -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Context for InfoPanel templates containing data item and edit mode state
|
|
||||||
/// </summary>
|
|
||||||
public record InfoPanelContext(object? DataItem, bool IsEditMode);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// InfoPanel component for displaying and editing grid row details
|
|
||||||
/// </summary>
|
|
||||||
public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPanelBase
|
|
||||||
{
|
|
||||||
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether to show readonly fields when in edit mode. Default is false.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter] public bool ShowReadOnlyFieldsInEditMode { get; set; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Minimum width for 2 columns layout. Default is 500px.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter] public int TwoColumnBreakpoint { get; set; } = 400;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Minimum width for 3 columns layout. Default is 800px.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter] public int ThreeColumnBreakpoint { get; set; } = 800;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Minimum width for 4 columns layout. Default is 1200px.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter] public int FourColumnBreakpoint { get; set; } = 1300;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fixed column count. If set (1-4), overrides responsive breakpoints. Default is null (responsive).
|
|
||||||
/// </summary>
|
|
||||||
[Parameter] public int? FixedColumnCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reference to the wrapper component - automatically registers this InfoPanel
|
|
||||||
/// </summary>
|
|
||||||
[CascadingParameter]
|
|
||||||
public MgGridWithInfoPanel? GridWrapper { get; set; }
|
|
||||||
|
|
||||||
private ElementReference _panelElement;
|
|
||||||
private bool _isJsInitialized;
|
|
||||||
private const int DefaultTopOffset = 300;
|
|
||||||
|
|
||||||
protected IMgGridBase? _currentGrid;
|
|
||||||
protected object? _currentDataItem;
|
|
||||||
protected int _focusedRowVisibleIndex = -1;
|
|
||||||
protected List<DxGridDataColumn> _allDataColumns = [];
|
|
||||||
|
|
||||||
// Edit mode state
|
|
||||||
protected bool _isEditMode;
|
|
||||||
protected object? _editModel;
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
{
|
|
||||||
GridWrapper?.RegisterInfoPanel(this);
|
|
||||||
await InitializeStickyAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task InitializeStickyAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await JSRuntime.InvokeVoidAsync(
|
|
||||||
"MgGridInfoPanel.initSticky",
|
|
||||||
_panelElement,
|
|
||||||
DefaultTopOffset);
|
|
||||||
_isJsInitialized = true;
|
|
||||||
}
|
|
||||||
catch (JSException)
|
|
||||||
{
|
|
||||||
// JS might not be loaded yet, ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Refreshes the InfoPanel with data from the specified grid row (view mode)
|
|
||||||
/// </summary>
|
|
||||||
public void RefreshData(IMgGridBase grid, object? dataItem, int visibleIndex = -1)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(grid);
|
|
||||||
|
|
||||||
_currentGrid = grid;
|
|
||||||
_currentDataItem = dataItem;
|
|
||||||
_focusedRowVisibleIndex = visibleIndex;
|
|
||||||
|
|
||||||
// Clear edit mode when refreshing with new data
|
|
||||||
_isEditMode = false;
|
|
||||||
_editModel = null;
|
|
||||||
|
|
||||||
// Use cached columns if available
|
|
||||||
if (_currentGrid != null && _currentDataItem != null)
|
|
||||||
{
|
|
||||||
var dataItemType = _currentDataItem.GetType();
|
|
||||||
_allDataColumns = GetAllDataColumnsCached(dataItemType, _currentGrid);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_allDataColumns = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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(IMgGridBase grid, object editModel)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(grid);
|
|
||||||
ArgumentNullException.ThrowIfNull(editModel);
|
|
||||||
|
|
||||||
_currentGrid = grid;
|
|
||||||
_editModel = editModel;
|
|
||||||
_isEditMode = true;
|
|
||||||
_currentDataItem = _editModel;
|
|
||||||
|
|
||||||
var dataItemType = _editModel.GetType();
|
|
||||||
_allDataColumns = GetAllDataColumnsCached(dataItemType, _currentGrid);
|
|
||||||
|
|
||||||
// Batch state changes
|
|
||||||
if (!_pendingStateChange)
|
|
||||||
{
|
|
||||||
_pendingStateChange = true;
|
|
||||||
InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
_pendingStateChange = false;
|
|
||||||
StateHasChanged();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Clears edit mode and returns to view mode
|
|
||||||
/// </summary>
|
|
||||||
public void ClearEditMode()
|
|
||||||
{
|
|
||||||
_isEditMode = false;
|
|
||||||
_editModel = null;
|
|
||||||
|
|
||||||
if (!_pendingStateChange)
|
|
||||||
{
|
|
||||||
_pendingStateChange = true;
|
|
||||||
InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
_pendingStateChange = false;
|
|
||||||
StateHasChanged();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Clears the InfoPanel completely
|
|
||||||
/// </summary>
|
|
||||||
public void Clear()
|
|
||||||
{
|
|
||||||
_currentGrid = null;
|
|
||||||
_currentDataItem = null;
|
|
||||||
_focusedRowVisibleIndex = -1;
|
|
||||||
_allDataColumns = [];
|
|
||||||
_isEditMode = false;
|
|
||||||
_editModel = null;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
if (_isJsInitialized)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await JSRuntime.InvokeVoidAsync("MgGridInfoPanel.disposeSticky", _panelElement);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Ignore disposal errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear all caches on dispose
|
|
||||||
_editSettingsCache.Clear();
|
|
||||||
_columnsCache.Clear();
|
|
||||||
_comboBoxTextCache.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the data item to display/edit (EditModel in edit mode, otherwise CurrentDataItem)
|
|
||||||
/// </summary>
|
|
||||||
protected object? GetActiveDataItem() => _isEditMode && _editModel != null ? _editModel : _currentDataItem;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the display text for a field using the grid's internal formatting.
|
|
||||||
/// For ComboBox columns, tries to get the text from the lookup data source.
|
|
||||||
/// </summary>
|
|
||||||
protected string GetDisplayTextFromGrid(DxGridDataColumn column)
|
|
||||||
{
|
|
||||||
var dataItem = GetActiveDataItem();
|
|
||||||
if (_currentGrid == null || dataItem == null || string.IsNullOrWhiteSpace(column.FieldName))
|
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var value = _currentGrid.GetDataItemValue(dataItem, column.FieldName);
|
|
||||||
|
|
||||||
if (value == null)
|
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
var dataItemType = dataItem.GetType();
|
|
||||||
|
|
||||||
// Try to resolve display text from EditSettings
|
|
||||||
var editSettings = GetEditSettingsCached(dataItemType, column.FieldName);
|
|
||||||
if (editSettings is DxComboBoxSettings comboSettings)
|
|
||||||
{
|
|
||||||
var displayText = ResolveComboBoxDisplayTextCached(dataItemType, comboSettings, value);
|
|
||||||
if (!string.IsNullOrEmpty(displayText))
|
|
||||||
return displayText;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply column's DisplayFormat if available
|
|
||||||
if (!string.IsNullOrEmpty(column.DisplayFormat))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return string.Format(column.DisplayFormat, value);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// If format fails, fall through to default formatting
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return FormatValue(value);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets edit settings for the specified field with Type-based caching
|
|
||||||
/// </summary>
|
|
||||||
private IEditSettings? GetEditSettingsCached(Type dataItemType, string fieldName)
|
|
||||||
{
|
|
||||||
if (_currentGrid == null || string.IsNullOrEmpty(fieldName))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var cacheKey = (dataItemType, fieldName);
|
|
||||||
|
|
||||||
if (_editSettingsCache.TryGetValue(cacheKey, out var cached))
|
|
||||||
return cached;
|
|
||||||
|
|
||||||
IEditSettings? settings = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Try each EditSettings type
|
|
||||||
settings = _currentGrid.GetColumnEditSettings<DxComboBoxSettings>(fieldName)
|
|
||||||
?? _currentGrid.GetColumnEditSettings<DxDateEditSettings>(fieldName)
|
|
||||||
?? _currentGrid.GetColumnEditSettings<DxTimeEditSettings>(fieldName)
|
|
||||||
?? _currentGrid.GetColumnEditSettings<DxSpinEditSettings>(fieldName)
|
|
||||||
?? _currentGrid.GetColumnEditSettings<DxCheckBoxSettings>(fieldName)
|
|
||||||
?? _currentGrid.GetColumnEditSettings<DxMemoSettings>(fieldName)
|
|
||||||
?? (IEditSettings?)_currentGrid.GetColumnEditSettings<DxTextBoxSettings>(fieldName);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
|
|
||||||
_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))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
foreach (var item in (System.Collections.IEnumerable)settings.Data)
|
|
||||||
{
|
|
||||||
if (item == null) continue;
|
|
||||||
|
|
||||||
var itemType = item.GetType();
|
|
||||||
var valueProperty = itemType.GetProperty(settings.ValueFieldName);
|
|
||||||
var textProperty = itemType.GetProperty(settings.TextFieldName);
|
|
||||||
|
|
||||||
if (valueProperty == null || textProperty == null) continue;
|
|
||||||
|
|
||||||
var itemValue = valueProperty.GetValue(item);
|
|
||||||
if (itemValue != null && itemValue.Equals(value))
|
|
||||||
{
|
|
||||||
return textProperty.GetValue(item)?.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// If lookup fails, return null and fall back to default formatting
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatValue(object? value)
|
|
||||||
{
|
|
||||||
if (value == null)
|
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
return value switch
|
|
||||||
{
|
|
||||||
DateTime dateTime => dateTime.ToString("yyyy-MM-dd HH:mm:ss"),
|
|
||||||
DateOnly dateOnly => dateOnly.ToString("yyyy-MM-dd"),
|
|
||||||
TimeOnly timeOnly => timeOnly.ToString("HH:mm:ss"),
|
|
||||||
TimeSpan timeSpan => timeSpan.ToString(@"hh\:mm\:ss"),
|
|
||||||
bool boolValue => boolValue ? "Igen" : "Nem",
|
|
||||||
decimal decValue => decValue.ToString("N2"),
|
|
||||||
double dblValue => dblValue.ToString("N2"),
|
|
||||||
float fltValue => fltValue.ToString("N2"),
|
|
||||||
int or long or short or byte => $"{value:N0}",
|
|
||||||
_ => value.ToString() ?? string.Empty
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the columns to display based on edit mode and ShowReadOnlyFieldsInEditMode setting
|
|
||||||
/// </summary>
|
|
||||||
protected IEnumerable<DxGridDataColumn> GetVisibleColumns()
|
|
||||||
{
|
|
||||||
if (!_isEditMode || ShowReadOnlyFieldsInEditMode)
|
|
||||||
{
|
|
||||||
return _allDataColumns;
|
|
||||||
}
|
|
||||||
|
|
||||||
// In edit mode with ShowReadOnlyFieldsInEditMode=false, hide readonly columns
|
|
||||||
return _allDataColumns.Where(c => !c.ReadOnly);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected object? GetCellValue(DxGridDataColumn column)
|
|
||||||
{
|
|
||||||
var dataItem = GetActiveDataItem();
|
|
||||||
if (_currentGrid == null || dataItem == null || string.IsNullOrWhiteSpace(column.FieldName))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return _currentGrid.GetDataItemValue(dataItem, column.FieldName);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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>();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var allColumns = grid.GetDataColumns();
|
|
||||||
|
|
||||||
if (allColumns != null)
|
|
||||||
{
|
|
||||||
foreach (var column in allColumns)
|
|
||||||
{
|
|
||||||
if (column is DxGridDataColumn dataColumn &&
|
|
||||||
!string.IsNullOrWhiteSpace(dataColumn.FieldName))
|
|
||||||
{
|
|
||||||
columns.Add(dataColumn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the EditSettings type for rendering logic
|
|
||||||
/// </summary>
|
|
||||||
private EditSettingsType GetEditSettingsType(DxGridDataColumn column)
|
|
||||||
{
|
|
||||||
var dataItem = GetActiveDataItem();
|
|
||||||
if (dataItem == null) return EditSettingsType.None;
|
|
||||||
|
|
||||||
var dataItemType = dataItem.GetType();
|
|
||||||
var settings = GetEditSettingsCached(dataItemType, column.FieldName);
|
|
||||||
|
|
||||||
return settings switch
|
|
||||||
{
|
|
||||||
DxComboBoxSettings => EditSettingsType.ComboBox,
|
|
||||||
DxDateEditSettings => EditSettingsType.DateEdit,
|
|
||||||
DxTimeEditSettings => EditSettingsType.TimeEdit,
|
|
||||||
DxSpinEditSettings => EditSettingsType.SpinEdit,
|
|
||||||
DxCheckBoxSettings => EditSettingsType.CheckBox,
|
|
||||||
DxMemoSettings => EditSettingsType.Memo,
|
|
||||||
_ => EditSettingsType.None
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum EditSettingsType
|
|
||||||
{
|
|
||||||
None,
|
|
||||||
ComboBox,
|
|
||||||
DateEdit,
|
|
||||||
TimeEdit,
|
|
||||||
SpinEdit,
|
|
||||||
CheckBox,
|
|
||||||
Memo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
/* MgGridInfoPanel scoped styles - component-specific overrides only */
|
|
||||||
/* Base styles are in wwwroot/css/mg-grid-info-panel.css */
|
|
||||||
|
|
||||||
/* Shared edit mode background color configuration */
|
|
||||||
/* Grid row background: #fffbeb (see MgGridBase.cs OnCustomizeElement) */
|
|
||||||
/* InfoPanel background: #fffbeb (see below .edit-mode) */
|
|
||||||
/* Border color: #f59e0b */
|
|
||||||
|
|
||||||
/* Edit/View mode transitions and specific colors */
|
|
||||||
.mg-grid-info-panel {
|
|
||||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-grid-info-panel.view-mode {
|
|
||||||
background-color: var(--DS-color-surface-neutral-subdued-rest, #f8f9fa) !important;
|
|
||||||
border-left: 3px solid transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fallback styles for info-panel-form (not in global CSS) */
|
|
||||||
.info-panel-form {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-panel-form .fw-semibold {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--DS-color-content-neutral-subdued-rest, #495057);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-panel-form .fw-semibold.text-primary {
|
|
||||||
color: var(--DS-color-content-primary-default-rest, #0d6efd);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text overflow handling - show ellipsis and full text in tooltip */
|
|
||||||
.info-panel-text-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-panel-text-wrapper input[readonly] {
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-info-panel-value-bool {
|
|
||||||
/* Keep left aligned */
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
namespace AyCode.Blazor.Components.Components.Grids;
|
|
||||||
|
|
||||||
public class MgGridInfoPanelHelper
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,462 +0,0 @@
|
||||||
using AyCode.Core.Helpers;
|
|
||||||
using AyCode.Core.Interfaces;
|
|
||||||
using AyCode.Core.Loggers;
|
|
||||||
using AyCode.Services.Server.SignalRs;
|
|
||||||
using AyCode.Services.SignalRs;
|
|
||||||
using DevExpress.Blazor;
|
|
||||||
using DevExpress.Data.Filtering;
|
|
||||||
using DevExpress.Data.Linq;
|
|
||||||
using DevExpress.Data.Linq.Helpers;
|
|
||||||
using System.Collections;
|
|
||||||
|
|
||||||
namespace AyCode.Blazor.Components.Components.Grids;
|
|
||||||
|
|
||||||
#region Models
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sorting information for a single field
|
|
||||||
/// </summary>
|
|
||||||
public class SignalRGridSortInfo
|
|
||||||
{
|
|
||||||
public string FieldName { get; set; } = "";
|
|
||||||
public bool Descending { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// GridCustomDataSource implementation that wraps AcSignalRDataSource.
|
|
||||||
/// Provides instant local filtering for previously seen filter criteria,
|
|
||||||
/// while refreshing data in background using SignalR callback pattern.
|
|
||||||
///
|
|
||||||
/// Key features:
|
|
||||||
/// - Uses AcSignalRDataSource for caching and background populate
|
|
||||||
/// - Tracks seen filter criteria - if already seen, returns local data instantly
|
|
||||||
/// - Background refresh with callback/populate pattern (no UI blocking)
|
|
||||||
/// - Full GridCustomDataSource support (filter, sort, page, group, summary)
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="TDataItem">Entity type implementing IId</typeparam>
|
|
||||||
/// <typeparam name="TId">ID type (int, Guid, long, etc.)</typeparam>
|
|
||||||
public class MgGridSignalRDataSource<TDataItem, TId> : GridCustomDataSource
|
|
||||||
where TDataItem : class, IId<TId>
|
|
||||||
where TId : struct
|
|
||||||
{
|
|
||||||
private readonly AcSignalRDataSource<TDataItem, TId, AcObservableCollection<TDataItem>> _innerDataSource;
|
|
||||||
private readonly AcLoggerBase? _logger;
|
|
||||||
|
|
||||||
// DevExpress CriteriaOperator to Expression converter
|
|
||||||
private readonly CriteriaToExpressionConverter _criteriaConverter = new();
|
|
||||||
|
|
||||||
// Track filter criteria that have been seen before
|
|
||||||
private readonly HashSet<string> _knownFilterCriteria = new(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
// Lock for thread-safe operations
|
|
||||||
private readonly object _syncLock = new();
|
|
||||||
|
|
||||||
// Event fired when background refresh completes
|
|
||||||
public event Action? OnBackgroundRefreshCompleted;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new MgGridSignalRDataSource wrapping an existing AcSignalRDataSource
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="innerDataSource">The underlying AcSignalRDataSource that handles caching and SignalR communication</param>
|
|
||||||
/// <param name="logger">Optional logger for debugging</param>
|
|
||||||
public MgGridSignalRDataSource(
|
|
||||||
AcSignalRDataSource<TDataItem, TId, AcObservableCollection<TDataItem>> innerDataSource,
|
|
||||||
AcLoggerBase? logger = null)
|
|
||||||
{
|
|
||||||
_innerDataSource = innerDataSource ?? throw new ArgumentNullException(nameof(innerDataSource));
|
|
||||||
_logger = logger;
|
|
||||||
|
|
||||||
// Subscribe to data source events
|
|
||||||
_innerDataSource.OnDataSourceLoaded += OnInnerDataSourceLoaded;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Specifies the data item type for the grid
|
|
||||||
/// </summary>
|
|
||||||
protected override Type DataItemType => typeof(TDataItem);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the inner AcSignalRDataSource for direct access if needed
|
|
||||||
/// </summary>
|
|
||||||
public AcSignalRDataSource<TDataItem, TId, AcObservableCollection<TDataItem>> InnerDataSource => _innerDataSource;
|
|
||||||
|
|
||||||
#region GridCustomDataSource Implementation
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the total count of items matching the current filter.
|
|
||||||
/// If filter was seen before, returns local count instantly and refreshes in background.
|
|
||||||
/// </summary>
|
|
||||||
public override async Task<int> GetItemCountAsync(
|
|
||||||
GridCustomDataSourceCountOptions options,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var filterKey = GetFilterKey(options.FilterCriteria);
|
|
||||||
|
|
||||||
_logger?.Debug($"[MgGridSignalRDataSource] GetItemCountAsync - Filter: {filterKey}");
|
|
||||||
|
|
||||||
// If we have local data and this filter was seen before, return local count
|
|
||||||
if (_innerDataSource.Count > 0 && IsKnownFilter(filterKey))
|
|
||||||
{
|
|
||||||
var localCount = ApplyLocalFilter(_innerDataSource.ToList(), options.FilterCriteria).Count;
|
|
||||||
|
|
||||||
_logger?.Debug($"[MgGridSignalRDataSource] Returning local count: {localCount}, refreshing in background");
|
|
||||||
|
|
||||||
// Refresh in background (fire-and-forget)
|
|
||||||
_ = RefreshInBackgroundAsync(filterKey);
|
|
||||||
|
|
||||||
return localCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// First time seeing this filter - must wait for server
|
|
||||||
_logger?.Debug("[MgGridSignalRDataSource] New filter, waiting for server data");
|
|
||||||
|
|
||||||
await LoadFromServerAsync();
|
|
||||||
MarkFilterAsKnown(filterKey);
|
|
||||||
|
|
||||||
return ApplyLocalFilter(_innerDataSource.ToList(), options.FilterCriteria).Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets items for the current page with filtering and sorting applied.
|
|
||||||
/// If filter was seen before, returns local data instantly and refreshes in background.
|
|
||||||
/// </summary>
|
|
||||||
public override async Task<IList> GetItemsAsync(
|
|
||||||
GridCustomDataSourceItemsOptions options,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var filterKey = GetFilterKey(options.FilterCriteria);
|
|
||||||
|
|
||||||
_logger?.Debug($"[MgGridSignalRDataSource] GetItemsAsync - Skip: {options.StartIndex}, Take: {options.Count}, Filter: {filterKey}");
|
|
||||||
|
|
||||||
// If we have local data and this filter was seen before, return local data
|
|
||||||
if (_innerDataSource.Count > 0 && IsKnownFilter(filterKey))
|
|
||||||
{
|
|
||||||
var localResult = GetLocalItems(options);
|
|
||||||
|
|
||||||
_logger?.Debug($"[MgGridSignalRDataSource] Returning {localResult.Count} local items, refreshing in background");
|
|
||||||
|
|
||||||
// Refresh in background (fire-and-forget)
|
|
||||||
RefreshInBackgroundAsync(filterKey).Forget();
|
|
||||||
|
|
||||||
return localResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
// First time seeing this filter - must wait for server
|
|
||||||
_logger?.Debug("[MgGridSignalRDataSource] New filter, waiting for server data");
|
|
||||||
|
|
||||||
await LoadFromServerAsync();
|
|
||||||
MarkFilterAsKnown(filterKey);
|
|
||||||
|
|
||||||
return GetLocalItems(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets unique values for a column (used in filter dropdowns).
|
|
||||||
/// Always returns from local data.
|
|
||||||
/// </summary>
|
|
||||||
public override Task<object[]> GetUniqueValuesAsync(
|
|
||||||
GridCustomDataSourceUniqueValuesOptions options,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
_logger?.Debug($"[MgGridSignalRDataSource] GetUniqueValuesAsync - Field: {options.FieldName}");
|
|
||||||
|
|
||||||
if (_innerDataSource.Count == 0)
|
|
||||||
return Task.FromResult(Array.Empty<object>());
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var propertyInfo = typeof(TDataItem).GetProperty(options.FieldName);
|
|
||||||
if (propertyInfo == null)
|
|
||||||
return Task.FromResult(Array.Empty<object>());
|
|
||||||
|
|
||||||
var uniqueValues = _innerDataSource
|
|
||||||
.Select(item => propertyInfo.GetValue(item))
|
|
||||||
.Where(v => v != null)
|
|
||||||
.Distinct()
|
|
||||||
.Cast<object>()
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
_logger?.Debug($"[MgGridSignalRDataSource] Found {uniqueValues.Length} unique values for {options.FieldName}");
|
|
||||||
|
|
||||||
return Task.FromResult(uniqueValues);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger?.Error($"[MgGridSignalRDataSource] GetUniqueValuesAsync failed: {ex.Message}", ex);
|
|
||||||
return Task.FromResult(Array.Empty<object>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets group information for grouped data.
|
|
||||||
/// Currently delegates to base implementation.
|
|
||||||
/// </summary>
|
|
||||||
public override async Task<IList<GridCustomDataSourceGroupInfo>> GetGroupInfoAsync(
|
|
||||||
GridCustomDataSourceGroupingOptions options,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
_logger?.Debug("[MgGridSignalRDataSource] GetGroupInfoAsync");
|
|
||||||
|
|
||||||
// TODO: Implement local grouping when needed
|
|
||||||
return await base.GetGroupInfoAsync(options, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets total summary values.
|
|
||||||
/// Calculates from local data.
|
|
||||||
/// </summary>
|
|
||||||
public override Task<IList> GetTotalSummaryAsync(
|
|
||||||
GridCustomDataSourceTotalSummaryOptions options,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
_logger?.Debug($"[MgGridSignalRDataSource] GetTotalSummaryAsync - Summaries: {options.SummaryInfo?.Count ?? 0}");
|
|
||||||
|
|
||||||
if (options.SummaryInfo == null || options.SummaryInfo.Count == 0 || _innerDataSource.Count == 0)
|
|
||||||
return Task.FromResult<IList>(new List<object?>());
|
|
||||||
|
|
||||||
var filteredData = ApplyLocalFilter(_innerDataSource.ToList(), options.FilterCriteria);
|
|
||||||
var summaryValues = new List<object?>();
|
|
||||||
|
|
||||||
foreach (var summaryInfo in options.SummaryInfo)
|
|
||||||
{
|
|
||||||
var value = CalculateSummary(filteredData, summaryInfo);
|
|
||||||
summaryValues.Add(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger?.Debug($"[MgGridSignalRDataSource] Calculated {summaryValues.Count} summary values");
|
|
||||||
|
|
||||||
return Task.FromResult<IList>(summaryValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Local Data Operations
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets items from local cache with filter, sort, and paging applied
|
|
||||||
/// </summary>
|
|
||||||
private List<TDataItem> GetLocalItems(GridCustomDataSourceItemsOptions options)
|
|
||||||
{
|
|
||||||
var data = _innerDataSource.ToList();
|
|
||||||
|
|
||||||
// Apply filter
|
|
||||||
var filtered = ApplyLocalFilter(data, options.FilterCriteria);
|
|
||||||
|
|
||||||
// Apply sorting
|
|
||||||
var sorted = ApplyLocalSort(filtered, options.SortInfo);
|
|
||||||
|
|
||||||
// Apply paging
|
|
||||||
var paged = sorted
|
|
||||||
.Skip(options.StartIndex)
|
|
||||||
.Take(options.Count)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return paged;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Applies CriteriaOperator filter to local data using DevExpress CriteriaToExpressionConverter
|
|
||||||
/// </summary>
|
|
||||||
private List<TDataItem> ApplyLocalFilter(List<TDataItem> data, CriteriaOperator? criteria)
|
|
||||||
{
|
|
||||||
if (criteria is null || data.Count == 0)
|
|
||||||
return data;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Use DevExpress built-in CriteriaToExpressionConverter
|
|
||||||
var filteredData = data.AsQueryable().AppendWhere(_criteriaConverter, criteria);
|
|
||||||
return filteredData.Cast<TDataItem>().ToList();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger?.Error($"[MgGridSignalRDataSource] Local filter failed: {ex.Message}", ex);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Applies sorting to local data
|
|
||||||
/// </summary>
|
|
||||||
private List<TDataItem> ApplyLocalSort(List<TDataItem> data, IReadOnlyList<GridCustomDataSourceSortInfo>? sortInfo)
|
|
||||||
{
|
|
||||||
if (sortInfo == null || sortInfo.Count == 0 || data.Count == 0)
|
|
||||||
return data;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
IOrderedEnumerable<TDataItem>? ordered = null;
|
|
||||||
|
|
||||||
for (var i = 0; i < sortInfo.Count; i++)
|
|
||||||
{
|
|
||||||
var sort = sortInfo[i];
|
|
||||||
var propertyInfo = typeof(TDataItem).GetProperty(sort.FieldName);
|
|
||||||
|
|
||||||
if (propertyInfo == null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
Func<TDataItem, object?> keySelector = item => propertyInfo.GetValue(item);
|
|
||||||
|
|
||||||
if (i == 0)
|
|
||||||
{
|
|
||||||
ordered = sort.DescendingSortOrder
|
|
||||||
? data.OrderByDescending(keySelector)
|
|
||||||
: data.OrderBy(keySelector);
|
|
||||||
}
|
|
||||||
else if (ordered != null)
|
|
||||||
{
|
|
||||||
ordered = sort.DescendingSortOrder
|
|
||||||
? ordered.ThenByDescending(keySelector)
|
|
||||||
: ordered.ThenBy(keySelector);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ordered?.ToList() ?? data;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger?.Error($"[MgGridSignalRDataSource] Local sort failed: {ex.Message}", ex);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculates a summary value for the given data
|
|
||||||
/// </summary>
|
|
||||||
private object? CalculateSummary(List<TDataItem> data, GridCustomDataSourceSummaryInfo summaryInfo)
|
|
||||||
{
|
|
||||||
if (data.Count == 0)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var propertyInfo = typeof(TDataItem).GetProperty(summaryInfo.FieldName);
|
|
||||||
|
|
||||||
return summaryInfo.SummaryType switch
|
|
||||||
{
|
|
||||||
GridSummaryItemType.Count => data.Count,
|
|
||||||
GridSummaryItemType.Sum when propertyInfo != null =>
|
|
||||||
data.Sum(item => Convert.ToDecimal(propertyInfo.GetValue(item) ?? 0)),
|
|
||||||
GridSummaryItemType.Min when propertyInfo != null =>
|
|
||||||
data.Min(item => propertyInfo.GetValue(item)),
|
|
||||||
GridSummaryItemType.Max when propertyInfo != null =>
|
|
||||||
data.Max(item => propertyInfo.GetValue(item)),
|
|
||||||
GridSummaryItemType.Avg when propertyInfo != null =>
|
|
||||||
data.Average(item => Convert.ToDecimal(propertyInfo.GetValue(item) ?? 0)),
|
|
||||||
_ => null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger?.Error($"[MgGridSignalRDataSource] Summary calculation failed: {ex.Message}", ex);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Filter Criteria Tracking
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a unique key for the filter criteria
|
|
||||||
/// </summary>
|
|
||||||
private string GetFilterKey(CriteriaOperator? criteria)
|
|
||||||
{
|
|
||||||
if (criteria is null)
|
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return CriteriaOperator.ToString(criteria);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if this filter has been seen before
|
|
||||||
/// </summary>
|
|
||||||
private bool IsKnownFilter(string filterKey)
|
|
||||||
{
|
|
||||||
lock (_syncLock)
|
|
||||||
{
|
|
||||||
return _knownFilterCriteria.Contains(filterKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Marks a filter as known (seen before)
|
|
||||||
/// </summary>
|
|
||||||
private void MarkFilterAsKnown(string filterKey)
|
|
||||||
{
|
|
||||||
lock (_syncLock)
|
|
||||||
{
|
|
||||||
_knownFilterCriteria.Add(filterKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Clears the known filter cache
|
|
||||||
/// </summary>
|
|
||||||
public void ClearKnownFilters()
|
|
||||||
{
|
|
||||||
lock (_syncLock)
|
|
||||||
{
|
|
||||||
_knownFilterCriteria.Clear();
|
|
||||||
}
|
|
||||||
_logger?.Debug("[MgGridSignalRDataSource] Known filters cleared");
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Server Communication
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Loads data from server synchronously (blocking)
|
|
||||||
/// </summary>
|
|
||||||
private Task LoadFromServerAsync()
|
|
||||||
{
|
|
||||||
_logger?.Debug("[MgGridSignalRDataSource] Loading from server (sync)");
|
|
||||||
return _innerDataSource.LoadDataSource();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Refreshes data in background using callback pattern (non-blocking)
|
|
||||||
/// </summary>
|
|
||||||
private Task RefreshInBackgroundAsync(string filterKey)
|
|
||||||
{
|
|
||||||
_logger?.Debug($"[MgGridSignalRDataSource] Starting background refresh for filter: {filterKey}");
|
|
||||||
|
|
||||||
// Use async callback version - this won't block
|
|
||||||
return _innerDataSource.LoadDataSourceAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Called when inner data source finishes loading
|
|
||||||
/// </summary>
|
|
||||||
private Task OnInnerDataSourceLoaded()
|
|
||||||
{
|
|
||||||
_logger?.Debug("[MgGridSignalRDataSource] Inner data source loaded, triggering refresh event");
|
|
||||||
OnBackgroundRefreshCompleted?.Invoke();
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Cleanup
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Invalidates all caches and clears known filters
|
|
||||||
/// </summary>
|
|
||||||
public void InvalidateCache()
|
|
||||||
{
|
|
||||||
ClearKnownFilters();
|
|
||||||
_logger?.Debug("[MgGridSignalRDataSource] Cache invalidated");
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
using DevExpress.Blazor;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
|
|
||||||
namespace AyCode.Blazor.Components.Components.Grids
|
|
||||||
{
|
|
||||||
public class MgGridToolbarBase : DxToolbar
|
|
||||||
{
|
|
||||||
[Parameter] public IMgGridBase Grid { get; set; }
|
|
||||||
[Parameter] public Func<ToolbarItemClickEventArgs, Task> RefreshClick { get; set; }
|
|
||||||
[Parameter] public bool ShowOnlyIcon { get; set; } = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
namespace AyCode.Blazor.Components.Components.Grids;
|
|
||||||
|
|
||||||
public class MgGridToolbarHelper
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
@using AyCode.Blazor.Components.Components.Grids
|
|
||||||
|
|
||||||
<MgGridToolbarBase @ref="GridToolbar" Grid="Grid" ItemRenderStyleMode="ToolbarRenderStyleMode.Plain" ShowOnlyIcon="ShowOnlyIcon">
|
|
||||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "New")" Click="NewItem_Click" IconCssClass="grid-new-row" Visible="@(!IsEditing)" Enabled="@(EnableNew && !IsSyncing)" />
|
|
||||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Edit")" Click="EditItem_Click" IconCssClass="grid-edit-row" Visible="@(!IsEditing)" Enabled="@(EnableEdit && HasFocusedRow && !IsSyncing)" />
|
|
||||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Delete")" Click="DeleteItem_Click" IconCssClass="grid-delete-row" Visible="@(!IsEditing)" Enabled="@(EnableDelete && HasFocusedRow && !IsSyncing)" />
|
|
||||||
|
|
||||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Save")" Click="SaveItem_Click" IconCssClass="grid-save" Visible="@IsEditing" RenderStyle="ButtonRenderStyle.Primary" />
|
|
||||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Cancel")" Click="CancelEdit_Click" IconCssClass="grid-cancel" Visible="@IsEditing" RenderStyle="ButtonRenderStyle.Secondary" />
|
|
||||||
|
|
||||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Prev Row")" BeginGroup="true" Click="PrevRow_Click" IconCssClass="grid-chevron-up" Enabled="@(HasFocusedRow && !IsSyncing && !IsEditing)" />
|
|
||||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Next Row")" Click="NextRow_Click" IconCssClass="grid-chevron-down" Enabled="@(HasFocusedRow && !IsSyncing && !IsEditing)" />
|
|
||||||
|
|
||||||
@if (!OnlyGridEditTools)
|
|
||||||
{
|
|
||||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Column Chooser")" BeginGroup="true" Click="ColumnChooserItem_Click" IconCssClass="grid-column-chooser" />
|
|
||||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Layout")" IconCssClass="grid-layout">
|
|
||||||
<Items>
|
|
||||||
<DxToolbarItem Text="Load Layout" Click="LoadLayout_Click" IconCssClass="grid-layout-load" Enabled="@_hasUserLayout" />
|
|
||||||
<DxToolbarItem Text="Save Layout" Click="SaveLayout_Click" IconCssClass="grid-layout-save" />
|
|
||||||
<DxToolbarItem BeginGroup="true" Text="Reset Layout" Click="ResetLayout_Click" IconCssClass="grid-layout-reset" />
|
|
||||||
</Items>
|
|
||||||
</DxToolbarItem>
|
|
||||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Export")" IconCssClass="grid-export" Visible="false" Enabled="@(HasFocusedRow && !IsEditing)">
|
|
||||||
<Items>
|
|
||||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "To CSV")" Click="ExportCsvItem_Click" IconCssClass="grid-export-xlsx" />
|
|
||||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "To XLSX")" Click="ExportXlsxItem_Click" IconCssClass="grid-export-xlsx" />
|
|
||||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "To XLS")" Click="ExportXlsItem_Click" IconCssClass="grid-export-xlsx" />
|
|
||||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "To PDF")" Click="ExportPdfItem_Click" IconCssClass="grid-export-pdf" />
|
|
||||||
</Items>
|
|
||||||
</DxToolbarItem>
|
|
||||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Reload data")" BeginGroup="true" Click="ReloadData_Click" IconCssClass="grid-refresh" Enabled="@(!IsSyncing && !_isReloadInProgress && !IsEditing)" />
|
|
||||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : FullscreenButtonText)" Click="Fullscreen_Click" IconCssClass="@FullscreenIconCssClass" Enabled="@(!IsEditing)" />
|
|
||||||
@ToolbarItemsExtended
|
|
||||||
}
|
|
||||||
</MgGridToolbarBase>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public bool OnlyGridEditTools { get; set; } = false;
|
|
||||||
[Parameter] public IMgGridBase Grid { get; set; } = null!;
|
|
||||||
[Parameter] public RenderFragment? ToolbarItemsExtended { get; set; }
|
|
||||||
[Parameter] public EventCallback<ToolbarItemClickEventArgs> OnReloadDataClick { get; set; }
|
|
||||||
|
|
||||||
[Parameter] public bool ShowOnlyIcon { get; set; } = false;
|
|
||||||
[Parameter] public bool EnableNew { get; set; } = true;
|
|
||||||
[Parameter] public bool EnableEdit { get; set; } = true;
|
|
||||||
[Parameter] public bool EnableDelete { get; set; } = false;
|
|
||||||
|
|
||||||
public MgGridToolbarBase GridToolbar { get; set; } = null!;
|
|
||||||
const string ExportFileName = "ExportResult";
|
|
||||||
|
|
||||||
private bool _hasUserLayout;
|
|
||||||
private bool _isReloadInProgress;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether the grid is currently in edit mode (New or Edit)
|
|
||||||
/// </summary>
|
|
||||||
private bool IsEditing => Grid?.GridEditState != MgGridEditState.None;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether the grid is currently syncing data
|
|
||||||
/// </summary>
|
|
||||||
private bool IsSyncing => Grid?.IsSyncing ?? false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether there is a focused row in the grid
|
|
||||||
/// </summary>
|
|
||||||
private bool HasFocusedRow => Grid?.GetFocusedRowIndex() >= 0;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether the grid is currently in fullscreen mode
|
|
||||||
/// </summary>
|
|
||||||
private bool IsFullscreenMode => Grid?.IsFullscreen ?? false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Button text for fullscreen toggle
|
|
||||||
/// </summary>
|
|
||||||
private string FullscreenButtonText => IsFullscreenMode ? "Exit Fullscreen" : "Fullscreen";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Icon class for fullscreen toggle button
|
|
||||||
/// </summary>
|
|
||||||
private string FullscreenIconCssClass => IsFullscreenMode ? "grid-fullscreen-exit" : "grid-fullscreen";
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
_hasUserLayout = await Grid.HasUserLayoutAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task ReloadData_Click(ToolbarItemClickEventArgs e)
|
|
||||||
{
|
|
||||||
_isReloadInProgress = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await OnReloadDataClick.InvokeAsync(e);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_isReloadInProgress = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task NewItem_Click()
|
|
||||||
{
|
|
||||||
await Grid.StartEditNewRowAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task EditItem_Click()
|
|
||||||
{
|
|
||||||
await Grid.StartEditRowAsync(Grid.GetFocusedRowIndex());
|
|
||||||
}
|
|
||||||
|
|
||||||
void DeleteItem_Click()
|
|
||||||
{
|
|
||||||
Grid.ShowRowDeleteConfirmation(Grid.GetFocusedRowIndex());
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task SaveItem_Click()
|
|
||||||
{
|
|
||||||
await Grid.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task CancelEdit_Click()
|
|
||||||
{
|
|
||||||
await Grid.CancelEditAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
void PrevRow_Click()
|
|
||||||
{
|
|
||||||
Grid.StepPrevRow();
|
|
||||||
}
|
|
||||||
|
|
||||||
void NextRow_Click()
|
|
||||||
{
|
|
||||||
Grid.StepNextRow();
|
|
||||||
}
|
|
||||||
|
|
||||||
void ColumnChooserItem_Click(ToolbarItemClickEventArgs e)
|
|
||||||
{
|
|
||||||
Grid.ShowColumnChooser();
|
|
||||||
}
|
|
||||||
|
|
||||||
void Fullscreen_Click()
|
|
||||||
{
|
|
||||||
Grid.ToggleFullscreen();
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task ExportXlsxItem_Click()
|
|
||||||
{
|
|
||||||
await Grid.ExportToXlsxAsync(ExportFileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task ExportXlsItem_Click()
|
|
||||||
{
|
|
||||||
await Grid.ExportToXlsAsync(ExportFileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task ExportCsvItem_Click()
|
|
||||||
{
|
|
||||||
await Grid.ExportToCsvAsync(ExportFileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task ExportPdfItem_Click()
|
|
||||||
{
|
|
||||||
await Grid.ExportToPdfAsync(ExportFileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task LoadLayout_Click()
|
|
||||||
{
|
|
||||||
await Grid.LoadUserLayoutAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task SaveLayout_Click()
|
|
||||||
{
|
|
||||||
await Grid.SaveUserLayoutAsync();
|
|
||||||
_hasUserLayout = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task ResetLayout_Click()
|
|
||||||
{
|
|
||||||
await Grid.ResetLayoutAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,209 +0,0 @@
|
||||||
@using DevExpress.Blazor
|
|
||||||
@inject Microsoft.JSInterop.IJSRuntime JSRuntime
|
|
||||||
|
|
||||||
<CascadingValue Value="this">
|
|
||||||
@if (_isFullscreen)
|
|
||||||
{
|
|
||||||
<div class="mg-fullscreen-overlay">
|
|
||||||
<div class="mg-fullscreen-header">
|
|
||||||
<span class="mg-fullscreen-title">@(_currentGrid?.Caption ?? "Grid")</span>
|
|
||||||
<button type="button" class="btn-close btn-close-white" aria-label="Close" @onclick="ExitFullscreen"></button>
|
|
||||||
</div>
|
|
||||||
<div class="mg-fullscreen-body">
|
|
||||||
@RenderMainContent()
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@RenderMainContent()
|
|
||||||
}
|
|
||||||
</CascadingValue>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private IInfoPanelBase? _infoPanelInstance;
|
|
||||||
private IMgGridBase? _currentGrid;
|
|
||||||
private bool _isFullscreen;
|
|
||||||
private string _currentInfoPanelSize = "400px";
|
|
||||||
private bool _sizeLoaded;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The grid content to display in the left pane
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public RenderFragment? GridContent { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// InfoPanel content (e.g., GridShippingDocumentInfoPanel) to display in the right pane.
|
|
||||||
/// If not set, the default MgGridInfoPanel is used.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public RenderFragment? ChildContent { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initial size of the InfoPanel pane. Default is "400px".
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public string InfoPanelSize { get; set; } = "400px";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether to show the InfoPanel. Default is true.
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public bool ShowInfoPanel { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether the wrapper is currently in fullscreen mode
|
|
||||||
/// </summary>
|
|
||||||
public bool IsFullscreen => _isFullscreen;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the InfoPanel instance for grid-InfoPanel communication
|
|
||||||
/// </summary>
|
|
||||||
public IInfoPanelBase? InfoPanelInstance
|
|
||||||
{
|
|
||||||
get => _infoPanelInstance;
|
|
||||||
set => _infoPanelInstance = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Registers an InfoPanel instance (called by child InfoPanel components)
|
|
||||||
/// </summary>
|
|
||||||
public void RegisterInfoPanel(IInfoPanelBase infoPanel)
|
|
||||||
{
|
|
||||||
_infoPanelInstance = infoPanel;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Registers the grid instance (called by MgGridBase)
|
|
||||||
/// </summary>
|
|
||||||
public void RegisterGrid(IMgGridBase grid)
|
|
||||||
{
|
|
||||||
_currentGrid = grid;
|
|
||||||
|
|
||||||
// Load saved size when grid is registered
|
|
||||||
if (!_sizeLoaded)
|
|
||||||
{
|
|
||||||
_ = LoadSavedSizeAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Toggles fullscreen mode
|
|
||||||
/// </summary>
|
|
||||||
public void ToggleFullscreen()
|
|
||||||
{
|
|
||||||
_isFullscreen = !_isFullscreen;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Enters fullscreen mode
|
|
||||||
/// </summary>
|
|
||||||
public void EnterFullscreen()
|
|
||||||
{
|
|
||||||
_isFullscreen = true;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exits fullscreen mode
|
|
||||||
/// </summary>
|
|
||||||
public void ExitFullscreen()
|
|
||||||
{
|
|
||||||
_isFullscreen = false;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetStorageKey() => _currentGrid != null
|
|
||||||
? $"Splitter_{_currentGrid.AutomaticLayoutStorageKey}"
|
|
||||||
: null!;
|
|
||||||
|
|
||||||
private async Task LoadSavedSizeAsync()
|
|
||||||
{
|
|
||||||
if (_currentGrid == null) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var storageKey = GetStorageKey();
|
|
||||||
var savedSize = await JSRuntime.InvokeAsync<string>("localStorage.getItem", storageKey);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(savedSize))
|
|
||||||
{
|
|
||||||
_currentInfoPanelSize = savedSize;
|
|
||||||
_sizeLoaded = true;
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_currentInfoPanelSize = InfoPanelSize;
|
|
||||||
_sizeLoaded = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Mute exceptions for the server prerender stage
|
|
||||||
_currentInfoPanelSize = InfoPanelSize;
|
|
||||||
_sizeLoaded = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveSizeAsync(string size)
|
|
||||||
{
|
|
||||||
if (_currentGrid == null) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var storageKey = GetStorageKey();
|
|
||||||
await JSRuntime.InvokeVoidAsync("localStorage.setItem", storageKey, size);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Mute exceptions for the server prerender stage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnInfoPanelSizeChanged(string newSize)
|
|
||||||
{
|
|
||||||
_currentInfoPanelSize = newSize;
|
|
||||||
await SaveSizeAsync(newSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
|
||||||
{
|
|
||||||
if (!_sizeLoaded)
|
|
||||||
{
|
|
||||||
_currentInfoPanelSize = InfoPanelSize;
|
|
||||||
}
|
|
||||||
base.OnParametersSet();
|
|
||||||
}
|
|
||||||
|
|
||||||
private RenderFragment RenderMainContent() => __builder =>
|
|
||||||
{
|
|
||||||
if (ShowInfoPanel)
|
|
||||||
{
|
|
||||||
<DxSplitter Width="100%" Height="@(_isFullscreen ? "100%" : null)" CssClass="mg-grid-with-info-panel" Orientation="Orientation.Horizontal">
|
|
||||||
<Panes>
|
|
||||||
<DxSplitterPane>
|
|
||||||
@GridContent
|
|
||||||
</DxSplitterPane>
|
|
||||||
<DxSplitterPane Size="@_currentInfoPanelSize" MinSize="0px" MaxSize="100%" AllowCollapse="true" CssClass="mg-info-panel-pane"
|
|
||||||
SizeChanged="OnInfoPanelSizeChanged">
|
|
||||||
@if (ChildContent != null)
|
|
||||||
{
|
|
||||||
@ChildContent
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MgGridInfoPanel />
|
|
||||||
}
|
|
||||||
</DxSplitterPane>
|
|
||||||
</Panes>
|
|
||||||
</DxSplitter>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@GridContent
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
# Grids
|
|
||||||
|
|
||||||
Core grid system built on DevExpress `DxGrid`. For the full technical reference see `AyCode.Blazor.Components/docs/MGGRID/README.md`.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
- **`MgGridBase.cs`** — `MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient>`, the main abstract grid component. Extends `DxGrid` with SignalR CRUD, layout persistence, master-detail hierarchy, edit state tracking, fullscreen toggle.
|
|
||||||
- **`MgGridWithInfoPanel.razor`** — `DxSplitter` wrapper: grid (left) + InfoPanel (right), fullscreen overlay, splitter size persistence.
|
|
||||||
- **`MgGridToolbarBase.cs`** — Extends `DxToolbar` with `Grid`, `RefreshClick`, and `ShowOnlyIcon` parameters.
|
|
||||||
- **`MgGridToolbarTemplate.razor`** — Full toolbar template: New/Edit/Delete/Save/Cancel, row navigation, layout menu (Load/Save/Reset), column chooser, export, reload, fullscreen. Extensible via `ToolbarItemsExtended`.
|
|
||||||
- **`MgGridDataColumn.cs`** — Extends `DxGridDataColumn` with InfoPanel parameters (`ShowInInfoPanel`, `InfoPanelOrder`, `InfoPanelDisplayFormat`) and `UrlLink` template with `{Property}` placeholder substitution via compiled accessors.
|
|
||||||
- **`MgGridInfoPanel.razor`** / **`.razor.cs`** — `MgGridInfoPanel` implementing `IInfoPanelBase`. Responsive column layout (1-4 columns with breakpoints), edit/view mode with typed editors, template system, sticky positioning via JS interop.
|
|
||||||
- **`MgGridSignalRDataSource.cs`** — `GridCustomDataSource` wrapping `AcSignalRDataSource`. Local cache for seen filter criteria, background refresh.
|
|
||||||
- **`GridEditMode.cs`** — `MgGridEditState` enum: `None`, `New`, `Edit`.
|
|
||||||
- **`MgGridHelper.cs`**, **`MgGridToolbarHelper.cs`**, **`MgGridInfoPanelHelper.cs`** — Placeholder helpers (empty).
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
namespace AyCode.Blazor.Components.Components;
|
|
||||||
|
|
||||||
public class MgComponentsHelper
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
@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,21 +0,0 @@
|
||||||
# Components
|
|
||||||
|
|
||||||
DevExpress component wrappers and grid infrastructure for the AyCode Blazor component library. Each `Ac*` class extends a DevExpress Blazor control to allow project-wide customization from a single point.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
- **`AcComponentBase.cs`** -- Abstract base class extending `DxComponentBase`.
|
|
||||||
- **`AcButton.cs`** -- Extends `DxButton`.
|
|
||||||
- **`AcTextBox.cs`** -- Extends `DxTextBox`.
|
|
||||||
- **`AcComboBox.cs`** -- Generic wrapper for `DxComboBox<TData, TValue>`.
|
|
||||||
- **`AcDxDateEdit.cs`** -- Generic wrapper for `DxDateEdit<T>`.
|
|
||||||
- **`AcFormLayoutItem.cs`** -- Extends `DxFormLayoutItem`.
|
|
||||||
- **`AcMaskedInput.cs`** -- Generic wrapper for `DxMaskedInput<T>`.
|
|
||||||
- **`AcMemo.cs`** -- Extends `DxMemo`.
|
|
||||||
- **`AcSpinEdit.cs`** -- Generic wrapper for `DxSpinEdit<T>`.
|
|
||||||
- **`MgComponentsHelper.cs`** -- Placeholder helper class (currently empty).
|
|
||||||
|
|
||||||
## Subfolders
|
|
||||||
|
|
||||||
- **`CardViews/`** -- Generic card-based view component with pagination.
|
|
||||||
- **`Grids/`** -- Core grid system with SignalR data binding, toolbar, info panel, and layout persistence.
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
# AyCode.Blazor.Components
|
|
||||||
|
|
||||||
@project {
|
|
||||||
type = "framework"
|
|
||||||
own-dep-projects = [
|
|
||||||
"AyCode.Core, AyCode.Entities, AyCode.Interfaces, AyCode.Models, AyCode.Services, AyCode.Services.Server, AyCode.Utils (in AyCode.Core repo)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
Blazor Razor component library targeting .NET 10. Provides reusable DevExpress-based UI components, a SignalR-powered grid system, and LINQ expression serialization services.
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
| Document | Topic |
|
|
||||||
|---|---|
|
|
||||||
| `MGGRID/README.md` | MgGrid system — overview, hierarchy, generic params, IMgGridBase interface |
|
|
||||||
| `MGGRID/MGGRID_PARAMETERS.md` | Component parameters, event callbacks, default grid settings |
|
|
||||||
| `MGGRID/MGGRID_CRUD.md` | Lifecycle, CRUD operations, edit flow, disposal |
|
|
||||||
| `MGGRID/MGGRID_LAYOUT.md` | Layout persistence (storage keys, tiers, operations) |
|
|
||||||
| `MGGRID/MGGRID_DETAIL.md` | Master-detail hierarchy |
|
|
||||||
| `MGGRID/MGGRID_RENDERING.md` | Fullscreen mode, rendering |
|
|
||||||
| `MGGRID/MGGRID_INFOPANEL.md` | MgGridInfoPanel, MgGridWithInfoPanel wrapper |
|
|
||||||
| `MGGRID/MGGRID_TOOLBAR.md` | MgGridToolbarTemplate (buttons, parameters, state) |
|
|
||||||
| `MGGRID/MGGRID_COLUMNS.md` | MgGridDataColumn (InfoPanel params, UrlLink) |
|
|
||||||
| `MGGRID/MGGRID_DATASOURCE.md` | MgGridSignalRDataSource (server-side data, local cache) |
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- **DevExpress.Blazor** 25.1.3, **DevExpress.Data** 25.1.3
|
|
||||||
- **Microsoft.AspNetCore.SignalR.Client** 9.0.11, **MessagePack** 3.1.4
|
|
||||||
- **Project refs:** AyCode.Blazor.Models, AyCode.Blazor.Models.Server
|
|
||||||
- **DLL refs:** AyCode.Core, AyCode.Entities, AyCode.Interfaces, AyCode.Models, AyCode.Services, AyCode.Services.Server, AyCode.Utils
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
- **`ExampleJsInterop.cs`** -- Scoped JS interop service that lazy-loads a JS module and exposes a `Prompt` method.
|
|
||||||
|
|
||||||
## Subfolders
|
|
||||||
|
|
||||||
- **`Components/`** -- DevExpress component wrappers and grid infrastructure.
|
|
||||||
- **`Services/`** -- Authentication, grid data source, and LINQ expression serialization helpers.
|
|
||||||
|
|
@ -6,6 +6,7 @@ using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using AyCode.Core.Interfaces;
|
||||||
using DevExpress.Data.Filtering.Helpers;
|
using DevExpress.Data.Filtering.Helpers;
|
||||||
using DevExpress.Data.Linq;
|
using DevExpress.Data.Linq;
|
||||||
using DevExpress.Data.Linq.Helpers;
|
using DevExpress.Data.Linq.Helpers;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using AyCode.Services.Logins;
|
||||||
|
|
||||||
|
namespace AyCode.Blazor.Components.Services;
|
||||||
|
|
||||||
|
public class AcSessionService<TSessionItem, TSessionItemId> where TSessionItem : IAcSessionItem<TSessionItemId> where TSessionItemId : notnull
|
||||||
|
{
|
||||||
|
public ConcurrentDictionary<TSessionItemId, TSessionItem> Sessions { get; private set; } = [];
|
||||||
|
|
||||||
|
public AcSessionService()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,284 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using AyCode.Core;
|
||||||
|
using AyCode.Core.Extensions;
|
||||||
|
using AyCode.Core.Helpers;
|
||||||
|
using AyCode.Core.Loggers;
|
||||||
|
using AyCode.Interfaces.Entities;
|
||||||
|
using AyCode.Services.Loggers;
|
||||||
|
using AyCode.Services.SignalRs;
|
||||||
|
using MessagePack.Resolvers;
|
||||||
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
|
|
||||||
|
namespace AyCode.Blazor.Components.Services
|
||||||
|
{
|
||||||
|
public abstract class AcSignalRClientBase : IAcSignalRHubClient
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<int, SignalRRequestModel> _responseByRequestId = new();
|
||||||
|
|
||||||
|
protected readonly HubConnection HubConnection;
|
||||||
|
protected readonly AcLoggerBase Logger;
|
||||||
|
|
||||||
|
public event Action<int, byte[], int?> OnMessageReceived = null!;
|
||||||
|
//public event Action<int, int> OnMessageRequested;
|
||||||
|
|
||||||
|
public int Timeout = 10000;
|
||||||
|
private const string TagsName = "SignalRTags";
|
||||||
|
|
||||||
|
protected AcSignalRClientBase(string fullHubName, AcLoggerBase logger)
|
||||||
|
{
|
||||||
|
Logger = logger;
|
||||||
|
|
||||||
|
HubConnection = new HubConnectionBuilder()
|
||||||
|
.WithUrl(fullHubName)
|
||||||
|
//.AddMessagePackProtocol(options => {
|
||||||
|
// options.SerializerOptions = MessagePackSerializerOptions.Standard
|
||||||
|
// .WithResolver(MessagePack.Resolvers.StandardResolver.Instance)
|
||||||
|
// .WithSecurity(MessagePackSecurity.UntrustedData)
|
||||||
|
// .WithCompression(MessagePackCompression.Lz4Block)
|
||||||
|
// .WithCompressionMinLength(256);})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
HubConnection.Closed += HubConnection_Closed;
|
||||||
|
|
||||||
|
_ = HubConnection.On<int, byte[], int?>(nameof(IAcSignalRHubClient.OnReceiveMessage), OnReceiveMessage);
|
||||||
|
//_ = HubConnection.On<int, int>(nameof(IAcSignalRHubClient.OnRequestMessage), OnRequestMessage);
|
||||||
|
|
||||||
|
HubConnection.StartAsync().Forget();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HubConnection_Closed(Exception? arg)
|
||||||
|
{
|
||||||
|
if (_responseByRequestId.IsEmpty) Logger.DebugConditional($"Client HubConnection_Closed");
|
||||||
|
else Logger.Warning($"Client HubConnection_Closed; {nameof(_responseByRequestId)} count: {_responseByRequestId.Count}");
|
||||||
|
|
||||||
|
_responseByRequestId.Clear();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartConnection()
|
||||||
|
{
|
||||||
|
if (HubConnection.State == HubConnectionState.Disconnected)
|
||||||
|
await HubConnection.StartAsync();
|
||||||
|
|
||||||
|
if (HubConnection.State != HubConnectionState.Connected)
|
||||||
|
await TaskHelper.WaitToAsync(() => HubConnection.State == HubConnectionState.Connected, Timeout, 10, 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopConnection()
|
||||||
|
{
|
||||||
|
await HubConnection.StopAsync();
|
||||||
|
await HubConnection.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual Task SendMessageToServerAsync(int messageTag)
|
||||||
|
=> SendMessageToServerAsync(messageTag, null, AcDomain.NextUniqueInt32);
|
||||||
|
|
||||||
|
public virtual Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int? requestId)
|
||||||
|
{
|
||||||
|
Logger.DebugConditional($"Client SendMessageToServerAsync; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}");
|
||||||
|
|
||||||
|
return StartConnection().ContinueWith(_ =>
|
||||||
|
{
|
||||||
|
var msgp = message?.ToMessagePack(ContractlessStandardResolver.Options);
|
||||||
|
return HubConnection.SendAsync(nameof(IAcSignalRHubClient.OnReceiveMessage), messageTag, msgp, requestId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#region CRUD
|
||||||
|
public virtual Task<TResponseData?> GetByIdAsync<TResponseData>(int messageTag, object id) //where TResponseData : class
|
||||||
|
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(id)), AcDomain.NextUniqueInt32);
|
||||||
|
public virtual Task GetByIdAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback, object id)
|
||||||
|
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(id)), responseCallback);
|
||||||
|
|
||||||
|
public virtual Task<TResponseData?> GetByIdAsync<TResponseData>(int messageTag, object[] ids) //where TResponseData : class
|
||||||
|
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(ids)), AcDomain.NextUniqueInt32);
|
||||||
|
public virtual Task GetByIdAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback, object[] ids)
|
||||||
|
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(ids)), responseCallback);
|
||||||
|
|
||||||
|
public virtual Task<TResponseData?> GetAllAsync<TResponseData>(int messageTag) //where TResponseData : class
|
||||||
|
=> SendMessageToServerAsync<TResponseData>(messageTag);
|
||||||
|
public virtual Task GetAllAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback)
|
||||||
|
=> SendMessageToServerAsync(messageTag, null, responseCallback);
|
||||||
|
|
||||||
|
public virtual Task GetAllAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback, object[]? contextParams)
|
||||||
|
=> SendMessageToServerAsync(messageTag, (contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage<IdMessage>(new IdMessage(contextParams))), responseCallback);
|
||||||
|
public virtual Task<TResponseData?> GetAllAsync<TResponseData>(int messageTag, object[]? contextParams) //where TResponseData : class
|
||||||
|
=> SendMessageToServerAsync<TResponseData>(messageTag, contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage<IdMessage>(new IdMessage(contextParams)), AcDomain.NextUniqueInt32);
|
||||||
|
|
||||||
|
public virtual Task<TPostData?> PostDataAsync<TPostData>(int messageTag, TPostData postData) where TPostData : class
|
||||||
|
=> SendMessageToServerAsync<TPostData>(messageTag, new SignalPostJsonDataMessage<TPostData>(postData), AcDomain.NextUniqueInt32);
|
||||||
|
public virtual Task<TResponseData?> PostDataAsync<TPostData, TResponseData>(int messageTag, TPostData postData) //where TPostData : class where TResponseData : class
|
||||||
|
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<TPostData>(postData), AcDomain.NextUniqueInt32);
|
||||||
|
|
||||||
|
public virtual Task PostDataAsync<TPostData>(int messageTag, TPostData postData, Func<ISignalResponseMessage<TPostData?>, Task> responseCallback) //where TPostData : class
|
||||||
|
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<TPostData>(postData), responseCallback);
|
||||||
|
public virtual Task PostDataAsync<TPostData, TResponseData>(int messageTag, TPostData postData, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback) //where TPostData : class where TResponseData : class
|
||||||
|
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<TPostData>(postData), responseCallback);
|
||||||
|
|
||||||
|
public Task GetAllIntoAsync<TResponseItem>(List<TResponseItem> intoList, int messageTag, object[]? contextParams = null, Action? callback = null) where TResponseItem : IEntityGuid
|
||||||
|
{
|
||||||
|
return GetAllAsync<List<TResponseItem>>(messageTag, response =>
|
||||||
|
{
|
||||||
|
var logText = $"GetAllIntoAsync<{typeof(TResponseItem).Name}>(); status: {response.Status}; dataCount: {response.ResponseData?.Count}; {ConstHelper.NameByValue(TagsName, messageTag)};";
|
||||||
|
|
||||||
|
intoList.Clear();
|
||||||
|
|
||||||
|
if (response.Status == SignalResponseStatus.Success && response.ResponseData != null)
|
||||||
|
{
|
||||||
|
Logger.Debug(logText);
|
||||||
|
intoList.AddRange(response.ResponseData);
|
||||||
|
}
|
||||||
|
else Logger.Error(logText);
|
||||||
|
|
||||||
|
callback?.Invoke();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}, contextParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion CRUD
|
||||||
|
|
||||||
|
public virtual Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag) //where TResponse : class
|
||||||
|
=> SendMessageToServerAsync<TResponse>(messageTag, null, AcDomain.NextUniqueInt32);
|
||||||
|
|
||||||
|
public virtual Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag, ISignalRMessage? message) //where TResponse : class
|
||||||
|
=> SendMessageToServerAsync<TResponse>(messageTag, message, AcDomain.NextUniqueInt32);
|
||||||
|
|
||||||
|
protected virtual async Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag, ISignalRMessage? message, int requestId) //where TResponse : class
|
||||||
|
{
|
||||||
|
Logger.DebugConditional($"Client SendMessageToServerAsync<TResult>; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}");
|
||||||
|
|
||||||
|
_responseByRequestId[requestId] = new SignalRRequestModel();
|
||||||
|
await SendMessageToServerAsync(messageTag, message, requestId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (await TaskHelper.WaitToAsync(() => _responseByRequestId[requestId].ResponseByRequestId != null, Timeout, 25, 50) &&
|
||||||
|
_responseByRequestId.TryRemove(requestId, out var obj) && obj.ResponseByRequestId is ISignalResponseMessage<string> responseMessage)
|
||||||
|
{
|
||||||
|
if (responseMessage.Status == SignalResponseStatus.Error || responseMessage.ResponseData == null)
|
||||||
|
{
|
||||||
|
var errorText = $"Client SendMessageToServerAsync<TResponseData> response error; await; tag: {messageTag}; Status: {responseMessage.Status}; requestId: {requestId};";
|
||||||
|
|
||||||
|
Logger.Error(errorText);
|
||||||
|
|
||||||
|
//TODO: Ideiglenes, majd a ResponseMessage-et kell visszaadni a Status miatt! - J.
|
||||||
|
return await Task.FromException<TResponse>(new Exception(errorText));
|
||||||
|
|
||||||
|
//throw new Exception(errorText);
|
||||||
|
//return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseMessage.ResponseData.JsonTo<TResponse>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error($"SendMessageToServerAsync; requestId: {requestId}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
_responseByRequestId.TryRemove(requestId, out _);
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual Task SendMessageToServerAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback)
|
||||||
|
=> SendMessageToServerAsync(messageTag, null, responseCallback);
|
||||||
|
|
||||||
|
public virtual Task SendMessageToServerAsync<TResponseData>(int messageTag, ISignalRMessage? message, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback)
|
||||||
|
{
|
||||||
|
if (messageTag == 0)
|
||||||
|
Logger.Error($"SendMessageToServerAsync; messageTag == 0");
|
||||||
|
|
||||||
|
var requestId = AcDomain.NextUniqueInt32;
|
||||||
|
|
||||||
|
_responseByRequestId[requestId] = new SignalRRequestModel(new Action<ISignalResponseMessage<string>>(responseMessage =>
|
||||||
|
{
|
||||||
|
TResponseData? responseData = default;
|
||||||
|
|
||||||
|
if (responseMessage.Status == SignalResponseStatus.Success)
|
||||||
|
{
|
||||||
|
responseData = string.IsNullOrEmpty(responseMessage.ResponseData) ? default : responseMessage.ResponseData.JsonTo<TResponseData?>();
|
||||||
|
}
|
||||||
|
else Logger.Error($"Client SendMessageToServerAsync<TResponseData> response error; callback; Status: {responseMessage.Status}; requestId: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}");
|
||||||
|
|
||||||
|
responseCallback(new SignalResponseMessage<TResponseData?>(messageTag, responseMessage.Status, responseData));
|
||||||
|
}));
|
||||||
|
|
||||||
|
return SendMessageToServerAsync(messageTag, message, requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual Task OnReceiveMessage(int messageTag, byte[] message, int? requestId)
|
||||||
|
{
|
||||||
|
var logText = $"Client OnReceiveMessage; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}";
|
||||||
|
|
||||||
|
if (message.Length == 0) Logger.Warning($"message.Length == 0! {logText}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (requestId.HasValue && _responseByRequestId.ContainsKey(requestId.Value))
|
||||||
|
{
|
||||||
|
var reqId = requestId.Value;
|
||||||
|
|
||||||
|
_responseByRequestId[reqId].ResponseDateTime = DateTime.UtcNow;
|
||||||
|
Logger.Info($"[{_responseByRequestId[reqId].ResponseDateTime.Subtract(_responseByRequestId[reqId].RequestDateTime).TotalMilliseconds:N0}ms][{(message.Length/1024)}kb]{logText}");
|
||||||
|
|
||||||
|
var responseMessage = message.MessagePackTo<SignalResponseJsonMessage>(ContractlessStandardResolver.Options);
|
||||||
|
|
||||||
|
switch (_responseByRequestId[reqId].ResponseByRequestId)
|
||||||
|
{
|
||||||
|
case null:
|
||||||
|
_responseByRequestId[reqId].ResponseByRequestId = responseMessage;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
case Action<ISignalResponseMessage<string>> messagePackCallback:
|
||||||
|
_responseByRequestId.TryRemove(reqId, out _);
|
||||||
|
|
||||||
|
messagePackCallback.Invoke(responseMessage);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
//case Action<string> jsonCallback:
|
||||||
|
// _responseByRequestId.TryRemove(reqId, out _);
|
||||||
|
|
||||||
|
// jsonCallback.Invoke(responseMessage);
|
||||||
|
// return Task.CompletedTask;
|
||||||
|
|
||||||
|
default:
|
||||||
|
Logger.Error($"Client OnReceiveMessage switch; unknown message type: {_responseByRequestId[reqId].ResponseByRequestId?.GetType().Name}; {ConstHelper.NameByValue(TagsName, messageTag)}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
_responseByRequestId.TryRemove(reqId, out _);
|
||||||
|
}
|
||||||
|
else Logger.Info(logText);
|
||||||
|
|
||||||
|
OnMessageReceived(messageTag, message, requestId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (requestId.HasValue)
|
||||||
|
_responseByRequestId.TryRemove(requestId.Value, out _);
|
||||||
|
|
||||||
|
Logger.Error($"Client OnReceiveMessage; requestId: {requestId}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
//public virtual Task OnRequestMessage(int messageTag, int requestId)
|
||||||
|
//{
|
||||||
|
// Logger.DebugConditional($"Client OnRequestMessage; {nameof(messageTag)}: {messageTag}; {nameof(requestId)}: {requestId};");
|
||||||
|
|
||||||
|
// try
|
||||||
|
// {
|
||||||
|
// OnMessageRequested(messageTag, requestId);
|
||||||
|
// }
|
||||||
|
// catch(Exception ex)
|
||||||
|
// {
|
||||||
|
// Logger.Error($"Client OnReceiveMessage; {nameof(messageTag)}: {messageTag}; {nameof(requestId)}: {requestId}; {ex.Message}", ex);
|
||||||
|
// throw;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return Task.CompletedTask;
|
||||||
|
|
||||||
|
//}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,356 +0,0 @@
|
||||||
using System.Linq.Expressions;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace AyCode.Blazor.Components.Services.ExpressionHelpers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deserializes AcExpressionNode DTO back to Expression tree.
|
|
||||||
/// </summary>
|
|
||||||
public class AcExpressionDeserializer
|
|
||||||
{
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
||||||
Converters = { new JsonStringEnumConverter() }
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly Dictionary<int, ParameterExpression> _parameters = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deserializes JSON to Expression.
|
|
||||||
/// </summary>
|
|
||||||
public static Expression ExpressionFromJson(string json, Type? entityType = null)
|
|
||||||
{
|
|
||||||
var node = JsonSerializer.Deserialize<AcExpressionNode>(json, JsonOptions)
|
|
||||||
?? throw new ArgumentException("Invalid expression JSON", nameof(json));
|
|
||||||
|
|
||||||
var deserializer = new AcExpressionDeserializer();
|
|
||||||
return deserializer.Deserialize(node, entityType);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deserializes JSON to typed Expression.
|
|
||||||
/// </summary>
|
|
||||||
public static Expression<Func<T, TResult>> ExpressionFromJson<T, TResult>(string json)
|
|
||||||
{
|
|
||||||
var expression = ExpressionFromJson(json, typeof(T));
|
|
||||||
return (Expression<Func<T, TResult>>)expression;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deserializes AcExpressionNode to Expression.
|
|
||||||
/// </summary>
|
|
||||||
public Expression Deserialize(AcExpressionNode node, Type? entityType = null)
|
|
||||||
{
|
|
||||||
return node.NodeType switch
|
|
||||||
{
|
|
||||||
ExpressionType.Lambda => DeserializeLambda(node, entityType),
|
|
||||||
ExpressionType.Parameter => DeserializeParameter(node),
|
|
||||||
ExpressionType.Constant => DeserializeConstant(node),
|
|
||||||
ExpressionType.MemberAccess => DeserializeMemberAccess(node, entityType),
|
|
||||||
ExpressionType.Call => DeserializeMethodCall(node, entityType),
|
|
||||||
ExpressionType.Conditional => DeserializeConditional(node, entityType),
|
|
||||||
ExpressionType.New => DeserializeNew(node, entityType),
|
|
||||||
ExpressionType.MemberInit => DeserializeMemberInit(node, entityType),
|
|
||||||
ExpressionType.NewArrayInit or ExpressionType.NewArrayBounds => DeserializeNewArray(node, entityType),
|
|
||||||
ExpressionType.Invoke => DeserializeInvocation(node, entityType),
|
|
||||||
ExpressionType.TypeIs or ExpressionType.TypeAs => DeserializeTypeBinary(node, entityType),
|
|
||||||
|
|
||||||
// Unary expressions
|
|
||||||
ExpressionType.Not or ExpressionType.Negate or ExpressionType.NegateChecked or
|
|
||||||
ExpressionType.Convert or ExpressionType.ConvertChecked or ExpressionType.ArrayLength or
|
|
||||||
ExpressionType.Quote or ExpressionType.UnaryPlus
|
|
||||||
=> DeserializeUnary(node, entityType),
|
|
||||||
|
|
||||||
// Binary expressions
|
|
||||||
ExpressionType.Add or ExpressionType.AddChecked or ExpressionType.Subtract or
|
|
||||||
ExpressionType.SubtractChecked or ExpressionType.Multiply or ExpressionType.MultiplyChecked or
|
|
||||||
ExpressionType.Divide or ExpressionType.Modulo or ExpressionType.Power or
|
|
||||||
ExpressionType.And or ExpressionType.AndAlso or ExpressionType.Or or ExpressionType.OrElse or
|
|
||||||
ExpressionType.ExclusiveOr or ExpressionType.Equal or ExpressionType.NotEqual or
|
|
||||||
ExpressionType.LessThan or ExpressionType.LessThanOrEqual or
|
|
||||||
ExpressionType.GreaterThan or ExpressionType.GreaterThanOrEqual or
|
|
||||||
ExpressionType.Coalesce or ExpressionType.ArrayIndex or
|
|
||||||
ExpressionType.LeftShift or ExpressionType.RightShift
|
|
||||||
=> DeserializeBinary(node, entityType),
|
|
||||||
|
|
||||||
_ => throw new NotSupportedException($"Expression type '{node.NodeType}' is not supported.")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Deserialize Methods
|
|
||||||
|
|
||||||
private LambdaExpression DeserializeLambda(AcExpressionNode node, Type? entityType)
|
|
||||||
{
|
|
||||||
// Create parameters
|
|
||||||
var parameters = new List<ParameterExpression>();
|
|
||||||
if (node.Parameters != null)
|
|
||||||
{
|
|
||||||
foreach (var paramNode in node.Parameters)
|
|
||||||
{
|
|
||||||
var paramType = entityType ?? ResolveType(paramNode.TypeName);
|
|
||||||
var param = Expression.Parameter(paramType, paramNode.Name);
|
|
||||||
_parameters[paramNode.Index] = param;
|
|
||||||
parameters.Add(param);
|
|
||||||
|
|
||||||
// Use entityType only for first parameter
|
|
||||||
entityType = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body = Deserialize(node.Body!, null);
|
|
||||||
return Expression.Lambda(body, parameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ParameterExpression DeserializeParameter(AcExpressionNode node)
|
|
||||||
{
|
|
||||||
if (node.ParameterIndex.HasValue && _parameters.TryGetValue(node.ParameterIndex.Value, out var param))
|
|
||||||
return param;
|
|
||||||
|
|
||||||
throw new InvalidOperationException($"Parameter '{node.ParameterName}' not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ConstantExpression DeserializeConstant(AcExpressionNode node)
|
|
||||||
{
|
|
||||||
var type = ResolveType(node.TypeName ?? "System.Object");
|
|
||||||
|
|
||||||
if (node.Value == null)
|
|
||||||
return Expression.Constant(null, type);
|
|
||||||
|
|
||||||
var value = JsonSerializer.Deserialize(node.Value, type, JsonOptions);
|
|
||||||
return Expression.Constant(value, type);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Expression DeserializeMemberAccess(AcExpressionNode node, Type? entityType)
|
|
||||||
{
|
|
||||||
if (node.Object == null)
|
|
||||||
{
|
|
||||||
// Static member access
|
|
||||||
var declaringType = ResolveType(node.DeclaringType!);
|
|
||||||
var member = declaringType.GetMember(node.MemberName!, BindingFlags.Public | BindingFlags.Static).FirstOrDefault()
|
|
||||||
?? throw new InvalidOperationException($"Static member '{node.MemberName}' not found on type '{declaringType.Name}'.");
|
|
||||||
return Expression.MakeMemberAccess(null, member);
|
|
||||||
}
|
|
||||||
|
|
||||||
var obj = Deserialize(node.Object, entityType);
|
|
||||||
var memberInfo = obj.Type.GetMember(node.MemberName!, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase).FirstOrDefault()
|
|
||||||
?? throw new InvalidOperationException($"Member '{node.MemberName}' not found on type '{obj.Type.Name}'.");
|
|
||||||
|
|
||||||
return Expression.MakeMemberAccess(obj, memberInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Expression DeserializeMethodCall(AcExpressionNode node, Type? entityType)
|
|
||||||
{
|
|
||||||
var arguments = node.Arguments?.Select(a => Deserialize(a, entityType)).ToArray() ?? [];
|
|
||||||
var argumentTypes = arguments.Select(a => a.Type).ToArray();
|
|
||||||
|
|
||||||
var declaringType = node.DeclaringType != null ? ResolveType(node.DeclaringType) : null;
|
|
||||||
var instance = node.Object != null ? Deserialize(node.Object, entityType) : null;
|
|
||||||
|
|
||||||
MethodInfo? method = null;
|
|
||||||
|
|
||||||
if (instance != null)
|
|
||||||
{
|
|
||||||
// Instance method
|
|
||||||
method = FindMethod(instance.Type, node.MethodName!, argumentTypes, isStatic: false);
|
|
||||||
}
|
|
||||||
else if (declaringType != null)
|
|
||||||
{
|
|
||||||
// Static method (including extension methods)
|
|
||||||
method = FindMethod(declaringType, node.MethodName!, argumentTypes, isStatic: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method == null)
|
|
||||||
throw new InvalidOperationException($"Method '{node.MethodName}' not found.");
|
|
||||||
|
|
||||||
// Handle generic methods
|
|
||||||
if (method.IsGenericMethodDefinition && node.GenericArguments?.Count > 0)
|
|
||||||
{
|
|
||||||
var genericTypes = node.GenericArguments.Select(ResolveType).ToArray();
|
|
||||||
method = method.MakeGenericMethod(genericTypes);
|
|
||||||
}
|
|
||||||
|
|
||||||
return instance != null
|
|
||||||
? Expression.Call(instance, method, arguments)
|
|
||||||
: Expression.Call(method, arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Expression DeserializeBinary(AcExpressionNode node, Type? entityType)
|
|
||||||
{
|
|
||||||
var left = Deserialize(node.Left!, entityType);
|
|
||||||
var right = Deserialize(node.Right!, entityType);
|
|
||||||
|
|
||||||
// Handle type mismatches (e.g., nullable comparisons)
|
|
||||||
if (left.Type != right.Type)
|
|
||||||
{
|
|
||||||
if (Nullable.GetUnderlyingType(left.Type) == right.Type)
|
|
||||||
right = Expression.Convert(right, left.Type);
|
|
||||||
else if (Nullable.GetUnderlyingType(right.Type) == left.Type)
|
|
||||||
left = Expression.Convert(left, right.Type);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Expression.MakeBinary(node.NodeType, left, right);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Expression DeserializeUnary(AcExpressionNode node, Type? entityType)
|
|
||||||
{
|
|
||||||
var operand = Deserialize(node.Operand!, entityType);
|
|
||||||
var type = node.TypeName != null ? ResolveType(node.TypeName) : null;
|
|
||||||
|
|
||||||
return node.NodeType switch
|
|
||||||
{
|
|
||||||
ExpressionType.Convert or ExpressionType.ConvertChecked when type != null
|
|
||||||
=> Expression.Convert(operand, type),
|
|
||||||
_ => Expression.MakeUnary(node.NodeType, operand, type)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private Expression DeserializeConditional(AcExpressionNode node, Type? entityType)
|
|
||||||
{
|
|
||||||
var test = Deserialize(node.Test!, entityType);
|
|
||||||
var ifTrue = Deserialize(node.IfTrue!, entityType);
|
|
||||||
var ifFalse = Deserialize(node.IfFalse!, entityType);
|
|
||||||
return Expression.Condition(test, ifTrue, ifFalse);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Expression DeserializeNew(AcExpressionNode node, Type? entityType)
|
|
||||||
{
|
|
||||||
var type = ResolveType(node.TypeName!);
|
|
||||||
var args = node.ConstructorArguments?.Select(a => Deserialize(a, entityType)).ToArray() ?? [];
|
|
||||||
var argTypes = args.Select(a => a.Type).ToArray();
|
|
||||||
var ctor = type.GetConstructor(argTypes)
|
|
||||||
?? throw new InvalidOperationException($"Constructor not found for type '{type.Name}'.");
|
|
||||||
return Expression.New(ctor, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Expression DeserializeMemberInit(AcExpressionNode node, Type? entityType)
|
|
||||||
{
|
|
||||||
var type = ResolveType(node.TypeName!);
|
|
||||||
var args = node.ConstructorArguments?.Select(a => Deserialize(a, entityType)).ToArray() ?? [];
|
|
||||||
var argTypes = args.Select(a => a.Type).ToArray();
|
|
||||||
var ctor = type.GetConstructor(argTypes) ?? type.GetConstructor(Type.EmptyTypes)
|
|
||||||
?? throw new InvalidOperationException($"Constructor not found for type '{type.Name}'.");
|
|
||||||
|
|
||||||
var newExpr = Expression.New(ctor, args);
|
|
||||||
var bindings = node.MemberBindings?.Select(b => DeserializeMemberBinding(b, type, entityType)).ToList()
|
|
||||||
?? [];
|
|
||||||
|
|
||||||
return Expression.MemberInit(newExpr, bindings);
|
|
||||||
}
|
|
||||||
|
|
||||||
private MemberBinding DeserializeMemberBinding(MemberBindingNode node, Type declaringType, Type? entityType)
|
|
||||||
{
|
|
||||||
var member = declaringType.GetMember(node.MemberName, BindingFlags.Public | BindingFlags.Instance).FirstOrDefault()
|
|
||||||
?? throw new InvalidOperationException($"Member '{node.MemberName}' not found on type '{declaringType.Name}'.");
|
|
||||||
|
|
||||||
return node.BindingType switch
|
|
||||||
{
|
|
||||||
MemberBindingType.Assignment => Expression.Bind(member, Deserialize(node.Expression!, entityType)),
|
|
||||||
MemberBindingType.MemberBinding => Expression.MemberBind(member,
|
|
||||||
node.Bindings?.Select(b => DeserializeMemberBinding(b, GetMemberType(member), entityType)) ?? []),
|
|
||||||
MemberBindingType.ListBinding => Expression.ListBind(member,
|
|
||||||
node.Initializers?.Select(args => Expression.ElementInit(
|
|
||||||
GetAddMethod(GetMemberType(member)),
|
|
||||||
args.Select(a => Deserialize(a, entityType)))) ?? []),
|
|
||||||
_ => throw new NotSupportedException($"Binding type '{node.BindingType}' is not supported.")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private Expression DeserializeNewArray(AcExpressionNode node, Type? entityType)
|
|
||||||
{
|
|
||||||
var elementType = ResolveType(node.TypeName!).GetElementType()
|
|
||||||
?? throw new InvalidOperationException("Cannot determine array element type.");
|
|
||||||
var elements = node.Elements?.Select(e => Deserialize(e, entityType)).ToArray() ?? [];
|
|
||||||
return Expression.NewArrayInit(elementType, elements);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Expression DeserializeInvocation(AcExpressionNode node, Type? entityType)
|
|
||||||
{
|
|
||||||
var expression = Deserialize(node.Object!, entityType);
|
|
||||||
var arguments = node.Arguments?.Select(a => Deserialize(a, entityType)).ToArray() ?? [];
|
|
||||||
return Expression.Invoke(expression, arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Expression DeserializeTypeBinary(AcExpressionNode node, Type? entityType)
|
|
||||||
{
|
|
||||||
var expression = Deserialize(node.Operand!, entityType);
|
|
||||||
var type = ResolveType(node.TypeName!);
|
|
||||||
return node.NodeType == ExpressionType.TypeIs
|
|
||||||
? Expression.TypeIs(expression, type)
|
|
||||||
: Expression.TypeAs(expression, type);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Helper Methods
|
|
||||||
|
|
||||||
private static MethodInfo? FindMethod(Type type, string methodName, Type[] argumentTypes, bool isStatic)
|
|
||||||
{
|
|
||||||
var bindingFlags = BindingFlags.Public | (isStatic ? BindingFlags.Static : BindingFlags.Instance);
|
|
||||||
|
|
||||||
// Try exact match first
|
|
||||||
var method = type.GetMethod(methodName, bindingFlags, null, argumentTypes, null);
|
|
||||||
if (method != null) return method;
|
|
||||||
|
|
||||||
// Try finding by name and parameter count
|
|
||||||
var candidates = type.GetMethods(bindingFlags)
|
|
||||||
.Where(m => m.Name == methodName && m.GetParameters().Length == argumentTypes.Length)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return candidates.FirstOrDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Type GetMemberType(MemberInfo member) => member switch
|
|
||||||
{
|
|
||||||
PropertyInfo pi => pi.PropertyType,
|
|
||||||
FieldInfo fi => fi.FieldType,
|
|
||||||
_ => throw new InvalidOperationException($"Cannot get type for member '{member.Name}'.")
|
|
||||||
};
|
|
||||||
|
|
||||||
private static MethodInfo GetAddMethod(Type collectionType)
|
|
||||||
{
|
|
||||||
return collectionType.GetMethod("Add")
|
|
||||||
?? throw new InvalidOperationException($"Add method not found on type '{collectionType.Name}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Type ResolveType(string typeName)
|
|
||||||
{
|
|
||||||
var type = typeName switch
|
|
||||||
{
|
|
||||||
"System.String" or "string" => typeof(string),
|
|
||||||
"System.Int32" or "int" => typeof(int),
|
|
||||||
"System.Int64" or "long" => typeof(long),
|
|
||||||
"System.Int16" or "short" => typeof(short),
|
|
||||||
"System.Byte" or "byte" => typeof(byte),
|
|
||||||
"System.Boolean" or "bool" => typeof(bool),
|
|
||||||
"System.Double" or "double" => typeof(double),
|
|
||||||
"System.Single" or "float" => typeof(float),
|
|
||||||
"System.Decimal" or "decimal" => typeof(decimal),
|
|
||||||
"System.DateTime" => typeof(DateTime),
|
|
||||||
"System.DateTimeOffset" => typeof(DateTimeOffset),
|
|
||||||
"System.DateOnly" => typeof(DateOnly),
|
|
||||||
"System.TimeOnly" => typeof(TimeOnly),
|
|
||||||
"System.TimeSpan" => typeof(TimeSpan),
|
|
||||||
"System.Guid" => typeof(Guid),
|
|
||||||
"System.Object" or "object" => typeof(object),
|
|
||||||
_ => Type.GetType(typeName)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (type == null && typeName.Contains("Nullable"))
|
|
||||||
{
|
|
||||||
var match = System.Text.RegularExpressions.Regex.Match(typeName, @"System\.Nullable`1\[\[(.+?),");
|
|
||||||
if (match.Success)
|
|
||||||
{
|
|
||||||
var underlyingType = ResolveType(match.Groups[1].Value);
|
|
||||||
type = typeof(Nullable<>).MakeGenericType(underlyingType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return type ?? throw new InvalidOperationException($"Cannot resolve type '{typeName}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
|
|
@ -1,238 +0,0 @@
|
||||||
using System.Linq.Expressions;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace AyCode.Blazor.Components.Services.ExpressionHelpers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Helper class for serializing and deserializing Expression trees and IQueryable queries.
|
|
||||||
/// Uses visitor pattern to handle all expression types automatically.
|
|
||||||
/// </summary>
|
|
||||||
public static class AcExpressionHelper
|
|
||||||
{
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
||||||
Converters = { new JsonStringEnumConverter() }
|
|
||||||
};
|
|
||||||
|
|
||||||
#region Expression Serialization
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Serializes an Expression to AcExpressionNode DTO.
|
|
||||||
/// </summary>
|
|
||||||
public static AcExpressionNode ExpressionToNode(Expression expression)
|
|
||||||
{
|
|
||||||
var visitor = new AcExpressionSerializerVisitor();
|
|
||||||
return visitor.Convert(expression);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Serializes an Expression to JSON string.
|
|
||||||
/// </summary>
|
|
||||||
public static string ExpressionToJson(Expression expression)
|
|
||||||
{
|
|
||||||
var node = ExpressionToNode(expression);
|
|
||||||
return JsonSerializer.Serialize(node, JsonOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Serializes a typed Expression to JSON string.
|
|
||||||
/// </summary>
|
|
||||||
public static string ExpressionToJson<TEntity, TResult>(Expression<Func<TEntity, TResult>> expression)
|
|
||||||
{
|
|
||||||
return ExpressionToJson((Expression)expression);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Expression Deserialization
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deserializes AcExpressionNode DTO to Expression.
|
|
||||||
/// </summary>
|
|
||||||
public static Expression ExpressionFromNode(AcExpressionNode node, Type? entityType = null)
|
|
||||||
{
|
|
||||||
var deserializer = new AcExpressionDeserializer();
|
|
||||||
return deserializer.Deserialize(node, entityType);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deserializes JSON to Expression.
|
|
||||||
/// </summary>
|
|
||||||
public static Expression ExpressionFromJson(string json, Type? entityType = null)
|
|
||||||
{
|
|
||||||
return AcExpressionDeserializer.ExpressionFromJson(json, entityType);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deserializes JSON to typed Expression.
|
|
||||||
/// </summary>
|
|
||||||
public static Expression<Func<TEntity, TResult>> ExpressionFromJson<TEntity, TResult>(string json)
|
|
||||||
{
|
|
||||||
return AcExpressionDeserializer.ExpressionFromJson<TEntity, TResult>(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region IQueryable Serialization
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Serializes an IQueryable's expression tree to JSON.
|
|
||||||
/// </summary>
|
|
||||||
public static string QueryToJson<T>(IQueryable<T> query)
|
|
||||||
{
|
|
||||||
return ExpressionToJson(query.Expression);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Serializes an IQueryable's expression tree to AcExpressionNode.
|
|
||||||
/// </summary>
|
|
||||||
public static AcExpressionNode QueryToNode<T>(IQueryable<T> query)
|
|
||||||
{
|
|
||||||
return ExpressionToNode(query.Expression);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region IQueryable Deserialization
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Applies a serialized query expression to an IQueryable source.
|
|
||||||
/// </summary>
|
|
||||||
public static IQueryable<T> ApplyQueryFromJson<T>(IQueryable<T> source, string json)
|
|
||||||
{
|
|
||||||
var node = JsonSerializer.Deserialize<AcExpressionNode>(json, JsonOptions)
|
|
||||||
?? throw new ArgumentException("Invalid query JSON", nameof(json));
|
|
||||||
|
|
||||||
return ApplyQueryFromNode(source, node);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Applies an AcExpressionNode query to an IQueryable source.
|
|
||||||
/// </summary>
|
|
||||||
public static IQueryable<T> ApplyQueryFromNode<T>(IQueryable<T> source, AcExpressionNode node)
|
|
||||||
{
|
|
||||||
// If the node is a method call (Where, OrderBy, etc.), we need to rebuild it
|
|
||||||
// with the source expression replaced
|
|
||||||
var expression = RebuildQueryExpression(source.Expression, node, typeof(T));
|
|
||||||
return source.Provider.CreateQuery<T>(expression);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Rebuilds a query expression, replacing the source with the provided expression.
|
|
||||||
/// </summary>
|
|
||||||
private static Expression RebuildQueryExpression(Expression sourceExpression, AcExpressionNode node, Type entityType)
|
|
||||||
{
|
|
||||||
if (node is { NodeType: ExpressionType.Call, MethodName: not null })
|
|
||||||
{
|
|
||||||
return RebuildMethodCallChain(sourceExpression, node, entityType);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it's just a lambda (filter expression), wrap it in a Where call
|
|
||||||
if (node.NodeType == ExpressionType.Lambda)
|
|
||||||
{
|
|
||||||
var deserializer = new AcExpressionDeserializer();
|
|
||||||
var lambda = deserializer.Deserialize(node, entityType);
|
|
||||||
|
|
||||||
var whereMethod = typeof(Queryable).GetMethods()
|
|
||||||
.First(m => m.Name == "Where" && m.GetParameters().Length == 2)
|
|
||||||
.MakeGenericMethod(entityType);
|
|
||||||
|
|
||||||
return Expression.Call(whereMethod, sourceExpression, lambda);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new NotSupportedException($"Cannot apply expression of type '{node.NodeType}' to IQueryable.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Rebuilds a chain of method calls (Where, OrderBy, Skip, Take, etc.)
|
|
||||||
/// </summary>
|
|
||||||
private static Expression RebuildMethodCallChain(Expression sourceExpression, AcExpressionNode node, Type entityType)
|
|
||||||
{
|
|
||||||
// First, process the inner expression (the source of this method call)
|
|
||||||
Expression currentSource;
|
|
||||||
|
|
||||||
if (node.Arguments?.Count > 0 && node.Arguments[0].NodeType == ExpressionType.Call)
|
|
||||||
{
|
|
||||||
// Recursive: rebuild the inner chain first
|
|
||||||
currentSource = RebuildMethodCallChain(sourceExpression, node.Arguments[0], entityType);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Base case: use the provided source
|
|
||||||
currentSource = sourceExpression;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now apply this method call
|
|
||||||
var methodName = node.MethodName!;
|
|
||||||
var declaringType = node.DeclaringType != null ? Type.GetType(node.DeclaringType) : typeof(Queryable);
|
|
||||||
|
|
||||||
// Find the method
|
|
||||||
var method = FindQueryableMethod(declaringType!, methodName, node.GenericArguments, node.Arguments?.Count ?? 1);
|
|
||||||
|
|
||||||
if (method == null)
|
|
||||||
throw new InvalidOperationException($"Method '{methodName}' not found.");
|
|
||||||
|
|
||||||
// Apply generic type arguments
|
|
||||||
if (method.IsGenericMethodDefinition && node.GenericArguments?.Count > 0)
|
|
||||||
{
|
|
||||||
var genericTypes = node.GenericArguments.Select(t => Type.GetType(t) ?? entityType).ToArray();
|
|
||||||
method = method.MakeGenericMethod(genericTypes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build arguments
|
|
||||||
var deserializer = new AcExpressionDeserializer();
|
|
||||||
var arguments = new List<Expression> { currentSource };
|
|
||||||
|
|
||||||
// Skip first argument (it's the source) and deserialize the rest
|
|
||||||
if (node.Arguments?.Count > 1)
|
|
||||||
{
|
|
||||||
for (var i = 1; i < node.Arguments.Count; i++)
|
|
||||||
{
|
|
||||||
var argNode = node.Arguments[i];
|
|
||||||
|
|
||||||
if (argNode.NodeType == ExpressionType.Quote && argNode.Operand != null)
|
|
||||||
{
|
|
||||||
// Quoted lambda - unquote and deserialize
|
|
||||||
var lambda = deserializer.Deserialize(argNode.Operand, entityType);
|
|
||||||
arguments.Add(Expression.Quote(lambda));
|
|
||||||
}
|
|
||||||
else if (argNode.NodeType == ExpressionType.Lambda)
|
|
||||||
{
|
|
||||||
var lambda = deserializer.Deserialize(argNode, entityType);
|
|
||||||
arguments.Add(Expression.Quote(lambda));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
arguments.Add(deserializer.Deserialize(argNode, entityType));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Expression.Call(method, arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static System.Reflection.MethodInfo? FindQueryableMethod(Type declaringType, string methodName, List<string>? genericArgs, int argCount)
|
|
||||||
{
|
|
||||||
return declaringType.GetMethods()
|
|
||||||
.Where(m => m.Name == methodName)
|
|
||||||
.FirstOrDefault(m =>
|
|
||||||
{
|
|
||||||
var parameters = m.GetParameters();
|
|
||||||
if (parameters.Length != argCount) return false;
|
|
||||||
|
|
||||||
// Check if generic argument count matches
|
|
||||||
if (m.IsGenericMethodDefinition)
|
|
||||||
{
|
|
||||||
var genericCount = genericArgs?.Count ?? 1;
|
|
||||||
if (m.GetGenericArguments().Length != genericCount) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
|
|
@ -1,214 +0,0 @@
|
||||||
using System.Linq.Expressions;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace AyCode.Blazor.Components.Services.ExpressionHelpers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Universal DTO representing any Expression node.
|
|
||||||
/// Recursively represents the entire Expression tree.
|
|
||||||
/// Serializable to JSON for transport over SignalR or HTTP.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AcExpressionNode
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The expression node type (Add, Equal, Call, MemberAccess, Lambda, etc.)
|
|
||||||
/// </summary>
|
|
||||||
public ExpressionType NodeType { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The CLR type name of this expression's result.
|
|
||||||
/// </summary>
|
|
||||||
public string? TypeName { get; set; }
|
|
||||||
|
|
||||||
#region Binary Expressions (Add, Equal, AndAlso, OrElse, etc.)
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Left operand for binary expressions.
|
|
||||||
/// </summary>
|
|
||||||
public AcExpressionNode? Left { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Right operand for binary expressions.
|
|
||||||
/// </summary>
|
|
||||||
public AcExpressionNode? Right { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Unary Expressions (Not, Convert, Negate, etc.)
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Operand for unary expressions.
|
|
||||||
/// </summary>
|
|
||||||
public AcExpressionNode? Operand { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Lambda Expressions
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Body of lambda expression.
|
|
||||||
/// </summary>
|
|
||||||
public AcExpressionNode? Body { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parameter definitions for lambda expressions.
|
|
||||||
/// </summary>
|
|
||||||
public List<ParameterNode>? Parameters { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Member Access
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Member/property/field name for MemberAccess expressions.
|
|
||||||
/// </summary>
|
|
||||||
public string? MemberName { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Object expression for member access or instance method calls.
|
|
||||||
/// </summary>
|
|
||||||
public AcExpressionNode? Object { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Declaring type for static members.
|
|
||||||
/// </summary>
|
|
||||||
public string? DeclaringType { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Method Call
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Method name for Call expressions.
|
|
||||||
/// </summary>
|
|
||||||
public string? MethodName { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Arguments for method calls.
|
|
||||||
/// </summary>
|
|
||||||
public List<AcExpressionNode>? Arguments { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generic type arguments for generic method calls.
|
|
||||||
/// </summary>
|
|
||||||
public List<string>? GenericArguments { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Constant
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Serialized constant value (JSON).
|
|
||||||
/// </summary>
|
|
||||||
public string? Value { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Parameter
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parameter name for Parameter expressions.
|
|
||||||
/// </summary>
|
|
||||||
public string? ParameterName { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parameter index (for matching parameters in lambda).
|
|
||||||
/// </summary>
|
|
||||||
public int? ParameterIndex { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Conditional (Ternary)
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Test expression for conditional expressions.
|
|
||||||
/// </summary>
|
|
||||||
public AcExpressionNode? Test { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// IfTrue branch for conditional expressions.
|
|
||||||
/// </summary>
|
|
||||||
public AcExpressionNode? IfTrue { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// IfFalse branch for conditional expressions.
|
|
||||||
/// </summary>
|
|
||||||
public AcExpressionNode? IfFalse { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region New Expression
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Constructor arguments for New expressions.
|
|
||||||
/// </summary>
|
|
||||||
public List<AcExpressionNode>? ConstructorArguments { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Member bindings for MemberInit expressions.
|
|
||||||
/// </summary>
|
|
||||||
public List<MemberBindingNode>? MemberBindings { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Array/Collection
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Elements for NewArrayInit expressions.
|
|
||||||
/// </summary>
|
|
||||||
public List<AcExpressionNode>? Elements { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a parameter definition in a lambda expression.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ParameterNode
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Parameter name.
|
|
||||||
/// </summary>
|
|
||||||
public string Name { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parameter type name.
|
|
||||||
/// </summary>
|
|
||||||
public string TypeName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parameter index in the lambda.
|
|
||||||
/// </summary>
|
|
||||||
public int Index { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a member binding in MemberInit expressions.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class MemberBindingNode
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The member name being bound.
|
|
||||||
/// </summary>
|
|
||||||
public string MemberName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The binding type (Assignment, MemberBinding, ListBinding).
|
|
||||||
/// </summary>
|
|
||||||
public MemberBindingType BindingType { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The expression being assigned (for Assignment bindings).
|
|
||||||
/// </summary>
|
|
||||||
public AcExpressionNode? Expression { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Nested bindings (for MemberMemberBinding).
|
|
||||||
/// </summary>
|
|
||||||
public List<MemberBindingNode>? Bindings { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Element initializers (for ListBinding).
|
|
||||||
/// </summary>
|
|
||||||
public List<List<AcExpressionNode>>? Initializers { get; set; }
|
|
||||||
}
|
|
||||||
|
|
@ -1,429 +0,0 @@
|
||||||
using System.Linq.Expressions;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace AyCode.Blazor.Components.Services.ExpressionHelpers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Expression visitor that serializes an Expression tree to AcExpressionNode DTO.
|
|
||||||
/// Handles all common expression types recursively.
|
|
||||||
/// </summary>
|
|
||||||
public class AcExpressionSerializerVisitor : ExpressionVisitor
|
|
||||||
{
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
||||||
Converters = { new JsonStringEnumConverter() }
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly Dictionary<ParameterExpression, int> _parameterIndexes = new();
|
|
||||||
private int _nextParameterIndex;
|
|
||||||
|
|
||||||
// Stack to collect converted nodes
|
|
||||||
private readonly Stack<AcExpressionNode> _nodeStack = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Converts an Expression to an AcExpressionNode DTO.
|
|
||||||
/// </summary>
|
|
||||||
public AcExpressionNode Convert(Expression expression)
|
|
||||||
{
|
|
||||||
_nodeStack.Clear();
|
|
||||||
_parameterIndexes.Clear();
|
|
||||||
_nextParameterIndex = 0;
|
|
||||||
|
|
||||||
VisitAndConvert(expression);
|
|
||||||
|
|
||||||
return _nodeStack.Count != 1 ? throw new InvalidOperationException($"Expected 1 node on stack, found {_nodeStack.Count}") : _nodeStack.Pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Serializes an Expression to JSON string.
|
|
||||||
/// </summary>
|
|
||||||
public string ToJson(Expression expression)
|
|
||||||
{
|
|
||||||
var node = Convert(expression);
|
|
||||||
return JsonSerializer.Serialize(node, JsonOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void VisitAndConvert(Expression expression)
|
|
||||||
{
|
|
||||||
Visit(expression);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Expression VisitBinary(BinaryExpression node)
|
|
||||||
{
|
|
||||||
VisitAndConvert(node.Left);
|
|
||||||
var left = _nodeStack.Pop();
|
|
||||||
|
|
||||||
VisitAndConvert(node.Right);
|
|
||||||
var right = _nodeStack.Pop();
|
|
||||||
|
|
||||||
_nodeStack.Push(new AcExpressionNode
|
|
||||||
{
|
|
||||||
NodeType = node.NodeType,
|
|
||||||
TypeName = node.Type.FullName,
|
|
||||||
Left = left,
|
|
||||||
Right = right
|
|
||||||
});
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Expression VisitUnary(UnaryExpression node)
|
|
||||||
{
|
|
||||||
VisitAndConvert(node.Operand);
|
|
||||||
var operand = _nodeStack.Pop();
|
|
||||||
|
|
||||||
_nodeStack.Push(new AcExpressionNode
|
|
||||||
{
|
|
||||||
NodeType = node.NodeType,
|
|
||||||
TypeName = node.Type.FullName,
|
|
||||||
Operand = operand
|
|
||||||
});
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Expression VisitLambda<T>(Expression<T> node)
|
|
||||||
{
|
|
||||||
// Register parameters with indexes
|
|
||||||
var parameters = new List<ParameterNode>();
|
|
||||||
foreach (var param in node.Parameters)
|
|
||||||
{
|
|
||||||
var index = _nextParameterIndex++;
|
|
||||||
_parameterIndexes[param] = index;
|
|
||||||
parameters.Add(new ParameterNode
|
|
||||||
{
|
|
||||||
Name = param.Name ?? $"p{index}",
|
|
||||||
TypeName = param.Type.FullName ?? param.Type.Name,
|
|
||||||
Index = index
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
VisitAndConvert(node.Body);
|
|
||||||
var body = _nodeStack.Pop();
|
|
||||||
|
|
||||||
_nodeStack.Push(new AcExpressionNode
|
|
||||||
{
|
|
||||||
NodeType = ExpressionType.Lambda,
|
|
||||||
TypeName = node.Type.FullName,
|
|
||||||
Body = body,
|
|
||||||
Parameters = parameters
|
|
||||||
});
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Expression VisitParameter(ParameterExpression node)
|
|
||||||
{
|
|
||||||
var index = _parameterIndexes.GetValueOrDefault(node, -1);
|
|
||||||
|
|
||||||
_nodeStack.Push(new AcExpressionNode
|
|
||||||
{
|
|
||||||
NodeType = ExpressionType.Parameter,
|
|
||||||
TypeName = node.Type.FullName,
|
|
||||||
ParameterName = node.Name,
|
|
||||||
ParameterIndex = index
|
|
||||||
});
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Expression VisitMember(MemberExpression node)
|
|
||||||
{
|
|
||||||
// Check if this is a closure variable access (captured variable)
|
|
||||||
if (IsClosureAccess(node))
|
|
||||||
{
|
|
||||||
var value = EvaluateClosureValue(node);
|
|
||||||
_nodeStack.Push(new AcExpressionNode
|
|
||||||
{
|
|
||||||
NodeType = ExpressionType.Constant,
|
|
||||||
TypeName = node.Type.FullName,
|
|
||||||
Value = SerializeValue(value)
|
|
||||||
});
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
AcExpressionNode? objectNode = null;
|
|
||||||
if (node.Expression != null)
|
|
||||||
{
|
|
||||||
VisitAndConvert(node.Expression);
|
|
||||||
objectNode = _nodeStack.Pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
_nodeStack.Push(new AcExpressionNode
|
|
||||||
{
|
|
||||||
NodeType = ExpressionType.MemberAccess,
|
|
||||||
TypeName = node.Type.FullName,
|
|
||||||
MemberName = node.Member.Name,
|
|
||||||
Object = objectNode,
|
|
||||||
DeclaringType = node.Member.DeclaringType?.FullName
|
|
||||||
});
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Expression VisitConstant(ConstantExpression node)
|
|
||||||
{
|
|
||||||
_nodeStack.Push(new AcExpressionNode
|
|
||||||
{
|
|
||||||
NodeType = ExpressionType.Constant,
|
|
||||||
TypeName = node.Type.FullName,
|
|
||||||
Value = SerializeValue(node.Value)
|
|
||||||
});
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Expression VisitMethodCall(MethodCallExpression node)
|
|
||||||
{
|
|
||||||
AcExpressionNode? objectNode = null;
|
|
||||||
if (node.Object != null)
|
|
||||||
{
|
|
||||||
VisitAndConvert(node.Object);
|
|
||||||
objectNode = _nodeStack.Pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
var arguments = new List<AcExpressionNode>();
|
|
||||||
foreach (var arg in node.Arguments)
|
|
||||||
{
|
|
||||||
VisitAndConvert(arg);
|
|
||||||
arguments.Add(_nodeStack.Pop());
|
|
||||||
}
|
|
||||||
|
|
||||||
var genericArgs = node.Method.IsGenericMethod
|
|
||||||
? node.Method.GetGenericArguments().Select(t => t.FullName ?? t.Name).ToList()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
_nodeStack.Push(new AcExpressionNode
|
|
||||||
{
|
|
||||||
NodeType = ExpressionType.Call,
|
|
||||||
TypeName = node.Type.FullName,
|
|
||||||
MethodName = node.Method.Name,
|
|
||||||
Object = objectNode,
|
|
||||||
Arguments = arguments,
|
|
||||||
DeclaringType = node.Method.DeclaringType?.FullName,
|
|
||||||
GenericArguments = genericArgs
|
|
||||||
});
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Expression VisitConditional(ConditionalExpression node)
|
|
||||||
{
|
|
||||||
VisitAndConvert(node.Test);
|
|
||||||
var test = _nodeStack.Pop();
|
|
||||||
|
|
||||||
VisitAndConvert(node.IfTrue);
|
|
||||||
var ifTrue = _nodeStack.Pop();
|
|
||||||
|
|
||||||
VisitAndConvert(node.IfFalse);
|
|
||||||
var ifFalse = _nodeStack.Pop();
|
|
||||||
|
|
||||||
_nodeStack.Push(new AcExpressionNode
|
|
||||||
{
|
|
||||||
NodeType = ExpressionType.Conditional,
|
|
||||||
TypeName = node.Type.FullName,
|
|
||||||
Test = test,
|
|
||||||
IfTrue = ifTrue,
|
|
||||||
IfFalse = ifFalse
|
|
||||||
});
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Expression VisitNew(NewExpression node)
|
|
||||||
{
|
|
||||||
var arguments = new List<AcExpressionNode>();
|
|
||||||
foreach (var arg in node.Arguments)
|
|
||||||
{
|
|
||||||
VisitAndConvert(arg);
|
|
||||||
arguments.Add(_nodeStack.Pop());
|
|
||||||
}
|
|
||||||
|
|
||||||
_nodeStack.Push(new AcExpressionNode
|
|
||||||
{
|
|
||||||
NodeType = ExpressionType.New,
|
|
||||||
TypeName = node.Type.FullName,
|
|
||||||
ConstructorArguments = arguments
|
|
||||||
});
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Expression VisitMemberInit(MemberInitExpression node)
|
|
||||||
{
|
|
||||||
var arguments = new List<AcExpressionNode>();
|
|
||||||
foreach (var arg in node.NewExpression.Arguments)
|
|
||||||
{
|
|
||||||
VisitAndConvert(arg);
|
|
||||||
arguments.Add(_nodeStack.Pop());
|
|
||||||
}
|
|
||||||
|
|
||||||
_nodeStack.Push(new AcExpressionNode
|
|
||||||
{
|
|
||||||
NodeType = ExpressionType.MemberInit,
|
|
||||||
TypeName = node.Type.FullName,
|
|
||||||
ConstructorArguments = arguments,
|
|
||||||
MemberBindings = node.Bindings.Select(ConvertMemberBinding).ToList()
|
|
||||||
});
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Expression VisitNewArray(NewArrayExpression node)
|
|
||||||
{
|
|
||||||
var elements = new List<AcExpressionNode>();
|
|
||||||
foreach (var expr in node.Expressions)
|
|
||||||
{
|
|
||||||
VisitAndConvert(expr);
|
|
||||||
elements.Add(_nodeStack.Pop());
|
|
||||||
}
|
|
||||||
|
|
||||||
_nodeStack.Push(new AcExpressionNode
|
|
||||||
{
|
|
||||||
NodeType = node.NodeType,
|
|
||||||
TypeName = node.Type.FullName,
|
|
||||||
Elements = elements
|
|
||||||
});
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Expression VisitTypeBinary(TypeBinaryExpression node)
|
|
||||||
{
|
|
||||||
VisitAndConvert(node.Expression);
|
|
||||||
var operand = _nodeStack.Pop();
|
|
||||||
|
|
||||||
_nodeStack.Push(new AcExpressionNode
|
|
||||||
{
|
|
||||||
NodeType = node.NodeType,
|
|
||||||
TypeName = node.TypeOperand.FullName,
|
|
||||||
Operand = operand
|
|
||||||
});
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Expression VisitInvocation(InvocationExpression node)
|
|
||||||
{
|
|
||||||
VisitAndConvert(node.Expression);
|
|
||||||
var objectNode = _nodeStack.Pop();
|
|
||||||
|
|
||||||
var arguments = new List<AcExpressionNode>();
|
|
||||||
foreach (var arg in node.Arguments)
|
|
||||||
{
|
|
||||||
VisitAndConvert(arg);
|
|
||||||
arguments.Add(_nodeStack.Pop());
|
|
||||||
}
|
|
||||||
|
|
||||||
_nodeStack.Push(new AcExpressionNode
|
|
||||||
{
|
|
||||||
NodeType = ExpressionType.Invoke,
|
|
||||||
TypeName = node.Type.FullName,
|
|
||||||
Object = objectNode,
|
|
||||||
Arguments = arguments
|
|
||||||
});
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Helper Methods
|
|
||||||
|
|
||||||
private MemberBindingNode ConvertMemberBinding(MemberBinding binding)
|
|
||||||
{
|
|
||||||
return binding switch
|
|
||||||
{
|
|
||||||
MemberAssignment assignment => ConvertMemberAssignment(assignment),
|
|
||||||
MemberMemberBinding memberBinding => new MemberBindingNode
|
|
||||||
{
|
|
||||||
MemberName = memberBinding.Member.Name,
|
|
||||||
BindingType = MemberBindingType.MemberBinding,
|
|
||||||
Bindings = memberBinding.Bindings.Select(ConvertMemberBinding).ToList()
|
|
||||||
},
|
|
||||||
MemberListBinding listBinding => new MemberBindingNode
|
|
||||||
{
|
|
||||||
MemberName = listBinding.Member.Name,
|
|
||||||
BindingType = MemberBindingType.ListBinding,
|
|
||||||
Initializers = listBinding.Initializers
|
|
||||||
.Select(i => i.Arguments.Select(ConvertArgument).ToList())
|
|
||||||
.ToList()
|
|
||||||
},
|
|
||||||
_ => throw new NotSupportedException($"Member binding type '{binding.BindingType}' is not supported.")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private MemberBindingNode ConvertMemberAssignment(MemberAssignment assignment)
|
|
||||||
{
|
|
||||||
VisitAndConvert(assignment.Expression);
|
|
||||||
var expr = _nodeStack.Pop();
|
|
||||||
|
|
||||||
return new MemberBindingNode
|
|
||||||
{
|
|
||||||
MemberName = assignment.Member.Name,
|
|
||||||
BindingType = MemberBindingType.Assignment,
|
|
||||||
Expression = expr
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private AcExpressionNode ConvertArgument(Expression expression)
|
|
||||||
{
|
|
||||||
VisitAndConvert(expression);
|
|
||||||
return _nodeStack.Pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsClosureAccess(MemberExpression node)
|
|
||||||
{
|
|
||||||
return node.Expression switch
|
|
||||||
{
|
|
||||||
ConstantExpression => true,
|
|
||||||
MemberExpression nested => IsClosureAccess(nested),
|
|
||||||
_ => false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static object? EvaluateClosureValue(MemberExpression node)
|
|
||||||
{
|
|
||||||
var objectStack = new Stack<MemberExpression>();
|
|
||||||
Expression? current = node;
|
|
||||||
|
|
||||||
while (current is MemberExpression me)
|
|
||||||
{
|
|
||||||
objectStack.Push(me);
|
|
||||||
current = me.Expression;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current is not ConstantExpression constant)
|
|
||||||
throw new InvalidOperationException("Expected constant at root of closure access.");
|
|
||||||
|
|
||||||
object? value = constant.Value;
|
|
||||||
|
|
||||||
while (objectStack.Count > 0)
|
|
||||||
{
|
|
||||||
var me = objectStack.Pop();
|
|
||||||
value = me.Member switch
|
|
||||||
{
|
|
||||||
FieldInfo fi => fi.GetValue(value),
|
|
||||||
PropertyInfo pi => pi.GetValue(value),
|
|
||||||
_ => throw new InvalidOperationException($"Unsupported member type: {me.Member.GetType()}")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? SerializeValue(object? value)
|
|
||||||
{
|
|
||||||
if (value == null) return null;
|
|
||||||
|
|
||||||
// Handle IQueryable source - serialize as placeholder
|
|
||||||
if (value is IQueryable)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return JsonSerializer.Serialize(value, JsonOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
# ExpressionHelpers
|
|
||||||
|
|
||||||
LINQ expression tree serialization and deserialization to JSON, enabling expression transport over SignalR or HTTP. Converts `Expression` trees to a recursive `AcExpressionNode` DTO and back, supporting binary, unary, lambda, member access, method calls, conditionals, new/member-init, and array expressions.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
- **`AcExpressionNode.cs`** -- Universal DTO representing any expression node. Includes `ParameterNode` and `MemberBindingNode` supporting types. Recursively models the full expression tree and is JSON-serializable.
|
|
||||||
- **`AcExpressionHelper.cs`** -- Static facade for serializing/deserializing expressions and `IQueryable` query trees. Provides `ExpressionToJson`, `ExpressionFromJson`, `QueryToJson`, and `ApplyQueryFromJson` methods.
|
|
||||||
- **`AcExpressionSerializerVisitor.cs`** -- `ExpressionVisitor` subclass that walks an expression tree and builds the `AcExpressionNode` graph using a stack-based approach. Handles closure variable evaluation and constant serialization.
|
|
||||||
- **`AcExpressionDeserializer.cs`** -- Reconstructs `Expression` trees from `AcExpressionNode` DTOs. Resolves types, parameters, methods, and member bindings. Handles nullable type mismatches in binary expressions.
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace AyCode.Blazor.Components.Services;
|
||||||
|
|
||||||
|
public interface IAcSessionItem<TSessionItemId> where TSessionItemId : notnull
|
||||||
|
{
|
||||||
|
public TSessionItemId SessionId { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
namespace AyCode.Blazor.Components.Services.Logins;
|
|
||||||
|
|
||||||
public class AcWebAuthService
|
|
||||||
{
|
|
||||||
public virtual void Logout()
|
|
||||||
{}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
# Logins
|
|
||||||
|
|
||||||
Web authentication service for the Blazor application.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
- **`AcWebAuthService.cs`** -- `AcWebAuthService` class with a virtual `Logout()` method. Intended as a base class for application-specific auth services to override with concrete logout logic.
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
# Services
|
|
||||||
|
|
||||||
Application services including authentication, grid data source prototyping, and LINQ expression tree serialization for SignalR/HTTP transport.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
- **`AcGridDataSource.cs`** -- Commented-out prototype of a `GridCustomDataSource` wrapping an `AcSignalRDataSource` with OData-style filtering. Not currently active; the working implementation lives in `Components/Grids/MgGridSignalRDataSource.cs`.
|
|
||||||
|
|
||||||
## Subfolders
|
|
||||||
|
|
||||||
- **`Logins/`** -- Web authentication service.
|
|
||||||
- **`ExpressionHelpers/`** -- LINQ expression tree serialization and deserialization to JSON for transport over SignalR or HTTP.
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
namespace AyCode.Blazor.Components.Services;
|
||||||
|
|
||||||
|
public class SignalRRequestModel
|
||||||
|
{
|
||||||
|
public DateTime RequestDateTime;
|
||||||
|
public DateTime ResponseDateTime;
|
||||||
|
public object? ResponseByRequestId = null;
|
||||||
|
|
||||||
|
public SignalRRequestModel()
|
||||||
|
{
|
||||||
|
RequestDateTime = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SignalRRequestModel(object responseByRequestId) : this()
|
||||||
|
{
|
||||||
|
ResponseByRequestId = responseByRequestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using AyCode.Core.Extensions;
|
||||||
|
|
||||||
|
namespace AyCode.Blazor.Components.Services;
|
||||||
|
|
||||||
|
public static class TrackingItemHelpers
|
||||||
|
{
|
||||||
|
public static T JsonClone<T>(T source) => source.ToJson().JsonTo<T>()!;
|
||||||
|
|
||||||
|
public static T ReflectionClone<T>(T source)
|
||||||
|
{
|
||||||
|
var type = source!.GetType();
|
||||||
|
|
||||||
|
if (type.IsPrimitive || typeof(string) == type)
|
||||||
|
return source;
|
||||||
|
|
||||||
|
if (type.IsArray)
|
||||||
|
{
|
||||||
|
var elementType = Type.GetType(type.FullName!.Replace("[]", string.Empty))!;
|
||||||
|
var array = (source as Array)!;
|
||||||
|
var cloned = Array.CreateInstance(elementType, array.Length);
|
||||||
|
|
||||||
|
for (var i = 0; i < array.Length; i++)
|
||||||
|
cloned.SetValue(ReflectionClone(array.GetValue(i)), i);
|
||||||
|
|
||||||
|
return (T)Convert.ChangeType(cloned, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
var clone = Activator.CreateInstance(type);
|
||||||
|
|
||||||
|
while (type != null && type != typeof(object))
|
||||||
|
{
|
||||||
|
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||||
|
{
|
||||||
|
var fieldValue = field.GetValue(source);
|
||||||
|
if (fieldValue == null) continue;
|
||||||
|
|
||||||
|
field.SetValue(clone, ReflectionClone(fieldValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
type = type.BaseType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (T)clone!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
# MgGrid — Columns
|
|
||||||
|
|
||||||
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
|
|
||||||
|
|
||||||
## MgGridDataColumn
|
|
||||||
|
|
||||||
Extended `DxGridDataColumn` with InfoPanel and URL link support.
|
|
||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `ShowInInfoPanel` | `bool` | `true` | Whether this column is visible in InfoPanel |
|
|
||||||
| `InfoPanelDisplayFormat` | `string?` | `null` | Custom display format for InfoPanel |
|
|
||||||
| `InfoPanelOrder` | `int` | `int.MaxValue` | Column order in InfoPanel (lower = earlier) |
|
|
||||||
| `UrlLink` | `string?` | `null` | URL template with `{Property}` placeholders |
|
|
||||||
|
|
||||||
**UrlLink example:** `https://admin.example.com/Entity/Edit/{Id}` — renders cell as `<a href="..." target="_blank">`.
|
|
||||||
|
|
||||||
Uses compiled property accessors (`ConcurrentDictionary` cache) for performance.
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
# MgGrid — Lifecycle & CRUD
|
|
||||||
|
|
||||||
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
|
|
||||||
|
|
||||||
## Lifecycle
|
|
||||||
|
|
||||||
```
|
|
||||||
1. OnInitializedAsync()
|
|
||||||
├── Validate Logger, SignalRClient (throw if null)
|
|
||||||
├── Create SignalRCrudTags from message tag parameters
|
|
||||||
├── Create TSignalRDataSource via Activator.CreateInstance(SignalRClient, crudTags, ContextIds)
|
|
||||||
├── Set DataSource.FilterText
|
|
||||||
├── Bind grid Data to data source inner list
|
|
||||||
└── Subscribe to: OnDataSourceLoaded, OnDataSourceItemChanged, OnSyncingStateChanged
|
|
||||||
|
|
||||||
2. SetParametersAsyncCore() [first time]
|
|
||||||
├── Set KeyFieldName = "Id"
|
|
||||||
├── Wire 6 DxGrid events → internal handlers (see MGGRID_PARAMETERS.md)
|
|
||||||
├── Add OnCustomizeElement handler (edit row highlighting, detail cell styling)
|
|
||||||
└── Set defaults: TextWrapEnabled=false, AllowSelectRowByClick=true, etc.
|
|
||||||
|
|
||||||
3. OnParametersSet() [first time]
|
|
||||||
├── Set GridName default: "{TDataItem.Name}Grid"
|
|
||||||
├── Set AutoSaveLayoutName default: "Grid{TDataItem.Name}"
|
|
||||||
├── Wire layout auto-loading/saving handlers
|
|
||||||
└── Register with GridWrapper via GridWrapper.RegisterGrid(this)
|
|
||||||
|
|
||||||
4. OnAfterRenderAsync(firstRender: true)
|
|
||||||
├── If DataSource parameter was provided: LoadDataSource(dataSourceParam, sync, notify)
|
|
||||||
└── Else: LoadDataSourceAsync(notify) — fires SignalR GetAll request
|
|
||||||
```
|
|
||||||
|
|
||||||
## CRUD Operations
|
|
||||||
|
|
||||||
### Adding Items
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
await grid.AddDataItem(item); // local add, sync later
|
|
||||||
await grid.AddDataItemAsync(item); // immediate server sync
|
|
||||||
|
|
||||||
await grid.InsertDataItem(0, item); // insert at index, sync later
|
|
||||||
await grid.InsertDataItemAsync(0, item); // insert at index, immediate sync
|
|
||||||
```
|
|
||||||
|
|
||||||
### Other CRUD Methods
|
|
||||||
|
|
||||||
| Method | Description |
|
|
||||||
|---|---|
|
|
||||||
| `UpdateDataItem(item)` | Local update, sync later |
|
|
||||||
| `UpdateDataItemAsync(item)` | Immediate server sync |
|
|
||||||
| `AddOrUpdateDataItem(item)` | Add if new, update if existing |
|
|
||||||
| `RemoveDataItem(item)` | Remove by entity reference |
|
|
||||||
| `RemoveDataItem(id)` | Remove by ID |
|
|
||||||
| `ReloadDataSourceAsync()` | Re-fetch all data from server |
|
|
||||||
| `ForceRenderAsync()` | Force grid re-initialization via new render key |
|
|
||||||
|
|
||||||
### ID Generation for New Items
|
|
||||||
|
|
||||||
New items get **temporary client-side IDs** until the server assigns real ones:
|
|
||||||
|
|
||||||
| TId Type | Strategy | Example |
|
|
||||||
|---|---|---|
|
|
||||||
| `Guid` | `Guid.NewGuid()` | `a1b2c3d4-...` |
|
|
||||||
| `int` | `-1 * AcDomain.NextUniqueInt32` | `-1`, `-2`, `-3`, ... |
|
|
||||||
|
|
||||||
**Convention:** Negative integer IDs indicate unsaved items. The server replaces them with real auto-increment IDs.
|
|
||||||
|
|
||||||
### Edit Flow (Inline)
|
|
||||||
|
|
||||||
```
|
|
||||||
User clicks Edit → OnEditStart → OnCustomizeEditModel
|
|
||||||
├── Set GridEditState = New/Edit
|
|
||||||
├── For new items: assign temp ID, set parent FK if detail grid
|
|
||||||
├── Notify InfoPanel: SetEditMode()
|
|
||||||
└── Fire OnGridCustomizeEditModel callback
|
|
||||||
|
|
||||||
User clicks Save → OnItemSaving
|
|
||||||
├── Fire OnGridEditModelSaving callback (can cancel)
|
|
||||||
├── If new: AddDataItemAsync / InsertDataItemAsync
|
|
||||||
├── If existing: UpdateDataItemAsync
|
|
||||||
├── Reset GridEditState = None
|
|
||||||
└── Clear InfoPanel edit mode
|
|
||||||
|
|
||||||
User clicks Cancel → OnEditCanceling
|
|
||||||
├── Reset GridEditState = None
|
|
||||||
└── Clear InfoPanel edit mode
|
|
||||||
```
|
|
||||||
|
|
||||||
### Edit Row Highlighting
|
|
||||||
|
|
||||||
When `GridEditState != None`, the focused row and its cells get `background-color: #fffbeb` (warm yellow) via `OnCustomizeElement`.
|
|
||||||
|
|
||||||
## Disposal
|
|
||||||
|
|
||||||
`DisposeAsync()` handles cleanup:
|
|
||||||
1. Set `_isDisposed = true` (guards all async callbacks)
|
|
||||||
2. Unsubscribe from `OnDataSourceLoaded`, `OnDataSourceItemChanged`, `OnSyncingStateChanged`
|
|
||||||
3. Remove `OnCustomizeElement` handler
|
|
||||||
4. `GC.SuppressFinalize(this)`
|
|
||||||
|
|
||||||
All async callbacks check `_isDisposed` before proceeding.
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
# MgGrid — DataSource
|
|
||||||
|
|
||||||
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
|
|
||||||
> For the underlying `AcSignalRDataSource`: `AyCode.Services.Server/docs/SIGNALR_DATASOURCE/README.md` (in AyCode.Core repo)
|
|
||||||
|
|
||||||
## MgGridSignalRDataSource
|
|
||||||
|
|
||||||
`GridCustomDataSource` wrapper around `AcSignalRDataSource` for server-side data operations.
|
|
||||||
|
|
||||||
- Returns local data instantly for previously-seen filter criteria
|
|
||||||
- Refreshes from the server in the background
|
|
||||||
- Handles filter, sort, paging, unique values, and summary calculations locally
|
|
||||||
- `OnBackgroundRefreshCompleted` event fires when background refresh completes
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
# MgGrid — Master-Detail
|
|
||||||
|
|
||||||
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
1. `MgGridBase.BuildRenderTree` wraps content in `CascadingValue<IMgGridBase>`
|
|
||||||
2. Child grids receive this via `[CascadingParameter] IMgGridBase? ParentGrid`
|
|
||||||
3. `IsMasterGrid` = `ParentDataItem == null`
|
|
||||||
4. `GetRootGrid()` walks the `ParentGrid` chain to find the topmost grid
|
|
||||||
|
|
||||||
## Detail Grid Setup
|
|
||||||
|
|
||||||
```razor
|
|
||||||
<DetailRowTemplate>
|
|
||||||
@{
|
|
||||||
var parent = (ParentEntity)context.DataItem;
|
|
||||||
<GridChildEntity ParentDataItem="@parent"
|
|
||||||
KeyFieldNameToParentId="ParentEntityId"
|
|
||||||
ContextIds="@(new object[] { parent.Id })" />
|
|
||||||
}
|
|
||||||
</DetailRowTemplate>
|
|
||||||
```
|
|
||||||
|
|
||||||
When `ParentDataItem` is set and `KeyFieldNameToParentId` is provided, new items automatically get their parent FK set via reflection.
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
# MgGrid — InfoPanel
|
|
||||||
|
|
||||||
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
|
|
||||||
|
|
||||||
## MgGridWithInfoPanel Wrapper
|
|
||||||
|
|
||||||
```razor
|
|
||||||
<MgGridWithInfoPanel ShowInfoPanel="true" InfoPanelSize="400px">
|
|
||||||
<GridContent>
|
|
||||||
<GridMyEntityBase @ref="Grid" ... />
|
|
||||||
</GridContent>
|
|
||||||
<ChildContent>
|
|
||||||
@* Optional: custom InfoPanel — if omitted, default MgGridInfoPanel is used *@
|
|
||||||
</ChildContent>
|
|
||||||
</MgGridWithInfoPanel>
|
|
||||||
```
|
|
||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `GridContent` | `RenderFragment` | — | The grid to display in the left pane |
|
|
||||||
| `ChildContent` | `RenderFragment?` | `null` | Custom InfoPanel. If `null`, renders `MgGridInfoPanel` |
|
|
||||||
| `ShowInfoPanel` | `bool` | `true` | Whether to show the right pane |
|
|
||||||
| `InfoPanelSize` | `string` | `"400px"` | Initial right pane size |
|
|
||||||
|
|
||||||
The wrapper provides:
|
|
||||||
- `DxSplitter` with collapsible right pane
|
|
||||||
- Fullscreen overlay (`mg-fullscreen-overlay`)
|
|
||||||
- Splitter size persistence (`Splitter_{key}` in localStorage)
|
|
||||||
- `RegisterGrid(grid)` — called by MgGridBase in `OnParametersSet`
|
|
||||||
- `RegisterInfoPanel(infoPanel)` — called by MgGridInfoPanel in `OnAfterRenderAsync`
|
|
||||||
|
|
||||||
## MgGridInfoPanel
|
|
||||||
|
|
||||||
Default InfoPanel component implementing `IInfoPanelBase`. Displays focused-row details with edit support.
|
|
||||||
|
|
||||||
### IInfoPanelBase Interface
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public interface IInfoPanelBase
|
|
||||||
{
|
|
||||||
void RefreshData(IMgGridBase grid, object? dataItem, int visibleIndex = -1);
|
|
||||||
void SetEditMode(IMgGridBase grid, object editModel);
|
|
||||||
void ClearEditMode();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### InfoPanel Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
FocusedRowChanged → InfoPanelInstance.RefreshData(grid, dataItem, visibleIndex)
|
|
||||||
Edit starts → InfoPanelInstance.SetEditMode(grid, editModel)
|
|
||||||
Edit ends/cancel → InfoPanelInstance.ClearEditMode()
|
|
||||||
```
|
|
||||||
|
|
||||||
`InfoPanelInstance` resolution: own `GridWrapper.InfoPanelInstance` → root grid's `GridWrapper.InfoPanelInstance` → `null`.
|
|
||||||
|
|
||||||
### Responsive Column Layout
|
|
||||||
|
|
||||||
| Breakpoint Parameter | Default | Columns |
|
|
||||||
|---|---|---|
|
|
||||||
| — | < 400px | 1 column |
|
|
||||||
| `TwoColumnBreakpoint` | 400px | 2 columns |
|
|
||||||
| `ThreeColumnBreakpoint` | 800px | 3 columns |
|
|
||||||
| `FourColumnBreakpoint` | 1300px | 4 columns |
|
|
||||||
|
|
||||||
`FixedColumnCount` (1-4) overrides responsive breakpoints if set.
|
|
||||||
|
|
||||||
### Template System
|
|
||||||
|
|
||||||
| Template | Context | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `HeaderTemplate` | `InfoPanelContext` | Custom header (default: grid Caption) |
|
|
||||||
| `BeforeColumnsTemplate` | `InfoPanelContext` | Content before column-value pairs |
|
|
||||||
| `ColumnsTemplate` | `InfoPanelContext` | Replace default column rendering entirely |
|
|
||||||
| `AfterColumnsTemplate` | `InfoPanelContext` | Content after column-value pairs |
|
|
||||||
| `FooterTemplate` | `InfoPanelContext` | Custom footer |
|
|
||||||
|
|
||||||
`InfoPanelContext` = `record(object? DataItem, bool IsEditMode)`.
|
|
||||||
|
|
||||||
### Edit Mode Editors (by property type)
|
|
||||||
|
|
||||||
| Type | Editor Component |
|
|
||||||
|---|---|
|
|
||||||
| `bool` | `DxCheckBox<bool>` |
|
|
||||||
| `DateTime` / `DateTime?` | `DxDateEdit<DateTime>` / `DxDateEdit<DateTime?>` |
|
|
||||||
| `DateOnly` / `DateOnly?` | `DxDateEdit<DateOnly>` / `DxDateEdit<DateOnly?>` |
|
|
||||||
| `int` | `DxSpinEdit<int>` |
|
|
||||||
| `decimal` | `DxSpinEdit<decimal>` |
|
|
||||||
| `double` | `DxSpinEdit<double>` |
|
|
||||||
| ComboBox (via `DxComboBoxSettings`) | `DxComboBox<TValue, TItem>` |
|
|
||||||
| Memo (via `EditSettingsType.Memo`) | `DxMemo` |
|
|
||||||
| Other | `DxTextBox` |
|
|
||||||
|
|
||||||
### Additional Features
|
|
||||||
|
|
||||||
- **Sticky positioning** via JS interop (`MgGridInfoPanel.initSticky`)
|
|
||||||
- **Built-in toolbar** with `MgGridToolbarTemplate` (`OnlyGridEditTools=true`)
|
|
||||||
- **OnDataItemChanged** callback when focused row changes
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
# MGGRID — Known Issues
|
|
||||||
|
|
||||||
For planned/actionable work see `MGGRID_TODO.md`.
|
|
||||||
|
|
||||||
No formally-tracked issues yet. In-code TODOs are recorded as TODO entries (`ACBLAZOR-GRID-T-*`) in `MGGRID_TODO.md`, since they describe unfinished work rather than confirmed broken behaviour.
|
|
||||||
|
|
||||||
Add the first `## ACBLAZOR-GRID-I-XXXX: ...` entry below when a concrete issue is observed.
|
|
||||||
|
|
||||||
## Issue entry template
|
|
||||||
|
|
||||||
```
|
|
||||||
## ACBLAZOR-GRID-I-XXXX: Short title
|
|
||||||
|
|
||||||
**Severity:** Trivial / Low / Minor / Major · **Status:** Open / Documented / Mitigated · **Area:** <subsystem>
|
|
||||||
|
|
||||||
### Description
|
|
||||||
What breaks, and under what conditions.
|
|
||||||
|
|
||||||
### Root cause
|
|
||||||
Why it happens (code location + design mismatch).
|
|
||||||
|
|
||||||
### Known workaround
|
|
||||||
Steps a consumer can take until fixed.
|
|
||||||
|
|
||||||
### Related TODO
|
|
||||||
`MGGRID_TODO.md#acblazor-grid-t-XXXX` (if applicable).
|
|
||||||
```
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
# MgGrid — Layout Persistence
|
|
||||||
|
|
||||||
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
|
|
||||||
|
|
||||||
## Storage Keys
|
|
||||||
|
|
||||||
Grid layouts are stored in **localStorage** with structured keys:
|
|
||||||
|
|
||||||
```
|
|
||||||
AutoSave: {AutoSaveLayoutName}_{MasterOrParentTypeName}_AutoSave_{UserId}
|
|
||||||
UserSave: {AutoSaveLayoutName}_{MasterOrParentTypeName}_UserSave_{UserId}
|
|
||||||
Splitter: Splitter_{grid.AutomaticLayoutStorageKey}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Examples:**
|
|
||||||
```
|
|
||||||
GridOrder_Master_AutoSave_42 ← master grid, user #42
|
|
||||||
GridOrder_Order_AutoSave_42 ← detail grid under Order parent
|
|
||||||
GridOrder_Master_UserSave_42 ← manually saved layout
|
|
||||||
Splitter_GridOrder_Master_AutoSave_42 ← splitter pane size
|
|
||||||
```
|
|
||||||
|
|
||||||
## Three Layout Tiers
|
|
||||||
|
|
||||||
| Tier | Key Contains | When Saved | When Loaded |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **Default** | (in-memory `_defaultLayoutJson`) | First `LayoutAutoLoading` — captures layout before any load | `ResetLayoutAsync()` — restores original |
|
|
||||||
| **AutoSave** | `_AutoSave_` | Every `LayoutAutoSaving` event (on any layout change) | Every `LayoutAutoLoading` event (on grid init, wrapped in `BeginUpdate`/`EndUpdate`) |
|
|
||||||
| **UserSave** | `_UserSave_` | `SaveUserLayoutAsync()` — explicit user action | `LoadUserLayoutAsync()` — explicit user action |
|
|
||||||
|
|
||||||
## Layout Operations
|
|
||||||
|
|
||||||
| Method | Behavior |
|
|
||||||
|---|---|
|
|
||||||
| `SaveUserLayoutAsync()` | Saves current layout to both UserSave AND AutoSave keys |
|
|
||||||
| `LoadUserLayoutAsync()` | Loads from UserSave key (if exists) |
|
|
||||||
| `ResetLayoutAsync()` | Removes AutoSave key, restores in-memory `_defaultLayoutJson` |
|
|
||||||
| `HasUserLayoutAsync()` | Checks if UserSave key exists in localStorage |
|
|
||||||
|
|
||||||
## Persisted State
|
|
||||||
|
|
||||||
The layout (`GridPersistentLayout`) includes: column order, column widths, sort descriptors, group descriptors, filter row values, page size — serialized as JSON via `System.Text.Json`.
|
|
||||||
|
|
||||||
## User Identification
|
|
||||||
|
|
||||||
`GetLayoutUserId()` is virtual — defaults to `0`. Override in project adapter to provide the logged-in user's ID.
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
# MgGrid — Parameters & Events
|
|
||||||
|
|
||||||
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
|
|
||||||
|
|
||||||
## Required Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `SignalRClient` | `AcSignalRClientBase` | SignalR client for server communication |
|
|
||||||
| `Logger` | `TLoggerClient` | Logger instance |
|
|
||||||
| `GetAllMessageTag` | `int` | SignalR tag for loading all items |
|
|
||||||
|
|
||||||
## CRUD Message Tags
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `GetAllMessageTag` | `int` | Tag for "get all items" request |
|
|
||||||
| `GetItemMessageTag` | `int` | Tag for "get single item" request |
|
|
||||||
| `AddMessageTag` | `int` | Tag for "add item" request |
|
|
||||||
| `UpdateMessageTag` | `int` | Tag for "update item" request |
|
|
||||||
| `RemoveMessageTag` | `int` | Tag for "remove item" request |
|
|
||||||
|
|
||||||
These are bundled into a `SignalRCrudTags` during `OnInitializedAsync`. See `AyCode.Services.Server/docs/SIGNALR_DATASOURCE/README.md` (in AyCode.Core repo) for details.
|
|
||||||
|
|
||||||
## Data & Context Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `DataSource` | `IList<TDataItem>` | Bind with `AcObservableCollection<TDataItem>` for external data. If not set, grid creates its own. |
|
|
||||||
| `ParentDataItem` | `IId<TId>?` | Parent entity for detail grids. `null` = master grid. |
|
|
||||||
| `KeyFieldNameToParentId` | `string?` | Property name on `TDataItem` that references the parent's ID |
|
|
||||||
| `ContextIds` | `object[]?` | Additional context passed to `AcSignalRDataSource` constructor |
|
|
||||||
| `FilterText` | `string?` | Text filter — propagated to data source, triggers reload |
|
|
||||||
|
|
||||||
## Display & Behavior Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `Caption` | `string` | `typeof(TDataItem).Name` | Grid title (shown in fullscreen header) |
|
|
||||||
| `GridName` | `string` | `"{TDataItem.Name}Grid"` | Name used in log messages |
|
|
||||||
| `AutoSaveLayoutName` | `string?` | `"Grid{TDataItem.Name}"` | Base name for layout storage keys |
|
|
||||||
|
|
||||||
## Event Callbacks
|
|
||||||
|
|
||||||
All grid events are re-exposed with `OnGrid` prefix to avoid collisions with `DxGrid` base events:
|
|
||||||
|
|
||||||
| Event | DxGrid Equivalent | When Fired |
|
|
||||||
|---|---|---|
|
|
||||||
| `OnGridItemDeleting` | `DataItemDeleting` | Before item removal (can cancel via `e.Cancel`) |
|
|
||||||
| `OnGridEditModelSaving` | `EditModelSaving` | Before item save (can cancel via `e.Cancel`) |
|
|
||||||
| `OnGridEditStart` | `EditStart` | When edit mode begins |
|
|
||||||
| `OnGridCustomizeEditModel` | `CustomizeEditModel` | When edit model is being prepared |
|
|
||||||
| `OnGridFocusedRowChanged` | `FocusedRowChanged` | When focused row changes |
|
|
||||||
| `OnDataSourceChanged` | — | After data source is loaded/reloaded |
|
|
||||||
| `OnGridItemChanged` | — | After server confirms a CRUD operation |
|
|
||||||
| `OnGridItemChanging` | — | Before a CRUD operation is sent to server |
|
|
||||||
|
|
||||||
Internal event wiring (in `SetParametersAsyncCore`, first call):
|
|
||||||
|
|
||||||
| DxGrid Event | → Internal Handler |
|
|
||||||
|---|---|
|
|
||||||
| `DataItemDeleting` | `OnItemDeleting` |
|
|
||||||
| `EditModelSaving` | `OnItemSaving` |
|
|
||||||
| `CustomizeEditModel` | `OnCustomizeEditModel` |
|
|
||||||
| `FocusedRowChanged` | `OnFocusedRowChanged` |
|
|
||||||
| `EditStart` | `OnEditStart` |
|
|
||||||
| `EditCanceling` | `OnEditCanceling` |
|
|
||||||
|
|
||||||
## Default Grid Settings
|
|
||||||
|
|
||||||
Set in `SetParametersAsyncCore` (first call only):
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---|---|
|
|
||||||
| `KeyFieldName` | `"Id"` |
|
|
||||||
| `TextWrapEnabled` | `false` |
|
|
||||||
| `AllowSelectRowByClick` | `true` |
|
|
||||||
| `HighlightRowOnHover` | `true` |
|
|
||||||
| `AutoCollapseDetailRow` | `true` |
|
|
||||||
| `AutoExpandAllGroupRows` | `false` |
|
|
||||||
|
|
||||||
Project adapters typically add more defaults in `OnParametersSet` (e.g., `EditMode`, `FocusedRowEnabled`, `PageSize`, `ShowFilterRow`, `SizeMode` based on `IsMasterGrid`).
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
# MgGrid — Fullscreen & Rendering
|
|
||||||
|
|
||||||
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
|
|
||||||
|
|
||||||
## Fullscreen Mode
|
|
||||||
|
|
||||||
Two modes depending on whether `MgGridWithInfoPanel` wraps the grid:
|
|
||||||
|
|
||||||
| Scenario | Behavior |
|
|
||||||
|---|---|
|
|
||||||
| **With wrapper** | `ToggleFullscreen()` delegates to wrapper — fullscreen includes grid + InfoPanel |
|
|
||||||
| **Standalone** | Grid renders its own `mg-fullscreen-overlay` with header (Caption + close button) and body |
|
|
||||||
|
|
||||||
## Rendering
|
|
||||||
|
|
||||||
`BuildRenderTree` uses manual render tree building (not Razor markup):
|
|
||||||
|
|
||||||
1. Outer `CascadingValue<IMgGridBase>` — provides this grid as `ParentGrid` to children
|
|
||||||
2. If standalone fullscreen: `div.mg-fullscreen-overlay` > `div.mg-fullscreen-header` + `div.mg-fullscreen-body` > `base.BuildRenderTree`
|
|
||||||
3. If normal: `div[style=display:contents]` > `base.BuildRenderTree`
|
|
||||||
4. `_gridRenderKey` (Guid) used as element key — changed by `ForceRenderAsync()` to force re-initialization
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
# MGGRID — TODO
|
|
||||||
|
|
||||||
For known issues / bugs see `MGGRID_ISSUES.md`.
|
|
||||||
|
|
||||||
## Priority legend
|
|
||||||
- **P0** blocker · **P1** important · **P2** nice-to-have · **P3** idea
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ACBLAZOR-GRID-T-V4P7: Generic ID generation in `MgGridBase.SetNewId`
|
|
||||||
**Priority:** P2 · **Type:** Refactor (framework-first) · **Origin:** 2026-04-24 in-code TODO audit · **Area:** `Components/Grids/MgGridBase.cs:460`
|
|
||||||
|
|
||||||
`SetNewId(TDataItem dataItem)` branches on `dataItem.Id is Guid` vs `dataItem.Id is int` to produce a new ID, and converts through `TypeConverter` back to `TId`. The in-code comment `//TODO: int !!! - J.` flags this as unfinished — the logic is not cleanly generic over `TId`.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private void SetNewId(TDataItem dataItem)
|
|
||||||
{
|
|
||||||
//TODO: int !!! - J.
|
|
||||||
if (dataItem.Id is Guid)
|
|
||||||
{
|
|
||||||
dataItem.Id = (TId)(_typeConverterId.ConvertTo(Guid.NewGuid(), typeof(TId)))!;
|
|
||||||
}
|
|
||||||
else if (dataItem.Id is int)
|
|
||||||
{
|
|
||||||
var newId = -1 * AcDomain.NextUniqueInt32;
|
|
||||||
dataItem.Id = (TId)(_typeConverterId.ConvertTo(newId, typeof(TId)))!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Problems:
|
|
||||||
- Runtime type switch on a generic parameter defeats the point of generics.
|
|
||||||
- Silent no-op for any other `TId` (e.g., `long`, `short`, custom struct) — no compile error, no throw.
|
|
||||||
- The negative-int convention (`-1 * AcDomain.NextUniqueInt32`) is not expressed as a contract; consumers cannot override.
|
|
||||||
|
|
||||||
### Fix options
|
|
||||||
- **(a)** Introduce `IAcNewIdGenerator<TId>` framework abstraction (in AyCode.Core) with default implementations for `Guid` and `int`. `MgGridBase` takes it via DI or generic parameter. Consumer-specific `TId` types register their generator.
|
|
||||||
- **(b)** Virtual protected method: `protected virtual TId GenerateNewId()` on `MgGridBase`, with default implementations for `Guid` and `int` preserved. Consumer overrides for custom `TId`.
|
|
||||||
- **(c)** Static strategy map keyed by `typeof(TId)` — registered once per app startup, resolved at runtime.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
- No runtime type-switch in `MgGridBase`.
|
|
||||||
- Throws explicit `NotSupportedException` (or similar) for unregistered `TId` types.
|
|
||||||
- Existing `Guid` and `int` consumers unaffected.
|
|
||||||
- Remove the `//TODO: int !!! - J.` comment.
|
|
||||||
|
|
||||||
## ACBLAZOR-GRID-T-S2L9: Implement local grouping in `MgGridSignalRDataSource.GetGroupInfoAsync`
|
|
||||||
**Priority:** P3 · **Type:** Feature · **Origin:** 2026-04-24 in-code TODO audit · **Area:** `Components/Grids/MgGridSignalRDataSource.cs:202`
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public override async Task<IList<GridCustomDataSourceGroupInfo>> GetGroupInfoAsync(
|
|
||||||
GridCustomDataSourceGroupingOptions options,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
_logger?.Debug("[MgGridSignalRDataSource] GetGroupInfoAsync");
|
|
||||||
|
|
||||||
// TODO: Implement local grouping when needed
|
|
||||||
return await base.GetGroupInfoAsync(options, cancellationToken);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Currently delegates to the DevExpress base implementation, which for a server-side `GridCustomDataSource` triggers a server round-trip. With `MgGridSignalRDataSource` already holding a local cache (see `MGGRID_DATASOURCE.md`), grouping over the cached rows would avoid that round-trip.
|
|
||||||
|
|
||||||
Not urgent — only light grouping usage so far; the base path works. Promote to P2 if a consumer hits perceptible grouping latency.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
- Local grouping computed from the cached list when the full dataset is cached.
|
|
||||||
- Falls back to base (server) path when the cache is partial / paginated.
|
|
||||||
- Unit / integration test with a grouped column.
|
|
||||||
- Remove the `// TODO: Implement local grouping when needed` comment.
|
|
||||||
|
|
||||||
## TODO entry template
|
|
||||||
|
|
||||||
```
|
|
||||||
## ACBLAZOR-GRID-T-XXXX: Short title
|
|
||||||
**Priority:** P0 / P1 / P2 / P3 · **Type:** Bug fix / Feature / Refactor / Docs · **Related:** `MGGRID_ISSUES.md#acblazor-grid-i-XXXX` (if applicable)
|
|
||||||
|
|
||||||
Description of what and why, including the trigger (user request, audit finding, incident).
|
|
||||||
|
|
||||||
Options / sub-tasks / acceptance criteria.
|
|
||||||
```
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
# MgGrid — Toolbar
|
|
||||||
|
|
||||||
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
|
|
||||||
|
|
||||||
## MgGridToolbarTemplate
|
|
||||||
|
|
||||||
The standard toolbar rendered inside grid's `ToolbarTemplate`. Provides all standard grid operations.
|
|
||||||
|
|
||||||
### Toolbar Buttons
|
|
||||||
|
|
||||||
| Group | Buttons | Visible |
|
|
||||||
|---|---|---|
|
|
||||||
| **CRUD** | New, Edit, Delete | When NOT editing |
|
|
||||||
| **Edit mode** | Save, Cancel | When editing |
|
|
||||||
| **Navigation** | Prev Row, Next Row | When NOT editing |
|
|
||||||
| **Layout** | Column Chooser, Layout (Load/Save/Reset) | When `OnlyGridEditTools=false` |
|
|
||||||
| **Actions** | Export (CSV/XLSX/XLS/PDF), Reload Data, Fullscreen | When `OnlyGridEditTools=false` |
|
|
||||||
|
|
||||||
### Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `Grid` | `IMgGridBase` | required | The grid to control |
|
|
||||||
| `OnlyGridEditTools` | `bool` | `false` | Show only CRUD + navigation (used by InfoPanel) |
|
|
||||||
| `ShowOnlyIcon` | `bool` | `false` | Hide button text, show only icons |
|
|
||||||
| `EnableNew` | `bool` | `true` | Enable "New" button |
|
|
||||||
| `EnableEdit` | `bool` | `true` | Enable "Edit" button |
|
|
||||||
| `EnableDelete` | `bool` | `false` | Enable "Delete" button |
|
|
||||||
| `ToolbarItemsExtended` | `RenderFragment?` | `null` | Extra toolbar items after standard buttons |
|
|
||||||
| `OnReloadDataClick` | `EventCallback` | — | Callback for "Reload Data" button |
|
|
||||||
|
|
||||||
### State Properties (computed from Grid)
|
|
||||||
|
|
||||||
| Property | Source |
|
|
||||||
|---|---|
|
|
||||||
| `IsEditing` | `Grid.GridEditState != MgGridEditState.None` |
|
|
||||||
| `IsSyncing` | `Grid.IsSyncing` |
|
|
||||||
| `HasFocusedRow` | `Grid.GetFocusedRowIndex() >= 0` |
|
|
||||||
| `IsFullscreenMode` | `Grid.IsFullscreen` |
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
# MgGrid System
|
|
||||||
|
|
||||||
> Comprehensive documentation for the **MgGrid** component family — the primary UI data grid pattern in the AyCode.Blazor framework.
|
|
||||||
> Source: `Components/Grids/`
|
|
||||||
> For SignalR transport: `AyCode.Services/docs/SIGNALR/README.md` (in AyCode.Core repo)
|
|
||||||
> For `AcSignalRDataSource`: `AyCode.Services.Server/docs/SIGNALR_DATASOURCE/README.md` (in AyCode.Core repo)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
**MgGridBase** is an abstract generic Blazor component that extends DevExpress `DxGrid` with:
|
|
||||||
- **Automatic SignalR CRUD** via `AcSignalRDataSource` (see AyCode.Core docs)
|
|
||||||
- **Layout persistence** — column order, widths, sorting, grouping auto-saved to `localStorage`
|
|
||||||
- **Master-detail hierarchy** — nested grids share context via `CascadingParameter`
|
|
||||||
- **InfoPanel integration** — side panel shows focused row details, supports edit mode
|
|
||||||
- **Fullscreen mode** — standalone overlay or via `MgGridWithInfoPanel` wrapper
|
|
||||||
- **Change tracking** — client-side tracking with server sync via `SaveChangesAsync`
|
|
||||||
|
|
||||||
## Detailed Documentation
|
|
||||||
|
|
||||||
| File | Topics |
|
|
||||||
|---|---|
|
|
||||||
| `MGGRID_PARAMETERS.md` | Component parameters (required, CRUD tags, data & context, display), event callbacks, default grid settings |
|
|
||||||
| `MGGRID_CRUD.md` | Lifecycle, CRUD operations, ID generation, edit flow, disposal |
|
|
||||||
| `MGGRID_LAYOUT.md` | Layout persistence (storage keys, three tiers, operations, persisted state) |
|
|
||||||
| `MGGRID_DETAIL.md` | Master-detail hierarchy |
|
|
||||||
| `MGGRID_RENDERING.md` | Fullscreen mode, rendering |
|
|
||||||
| `MGGRID_INFOPANEL.md` | MgGridInfoPanel, MgGridWithInfoPanel wrapper, responsive layout, templates, editors |
|
|
||||||
| `MGGRID_TOOLBAR.md` | MgGridToolbarTemplate (buttons, parameters, state) |
|
|
||||||
| `MGGRID_COLUMNS.md` | MgGridDataColumn (InfoPanel params, UrlLink) |
|
|
||||||
| `MGGRID_DATASOURCE.md` | MgGridSignalRDataSource (server-side data, local cache, background refresh) |
|
|
||||||
| `MGGRID_ISSUES.md` | Known issues (`ACBLAZOR-GRID-I-*`, `ACBLAZOR-GRID-B-*`) — currently none formally tracked |
|
|
||||||
| `MGGRID_TODO.md` | Planned work (`ACBLAZOR-GRID-T-*`) — refactors, missing features, optimizations |
|
|
||||||
|
|
||||||
## Component Hierarchy
|
|
||||||
|
|
||||||
```
|
|
||||||
DxGrid (DevExpress)
|
|
||||||
└── MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient> (AyCode.Blazor — abstract)
|
|
||||||
└── [Project-specific adapter] ← consumer fixes TSignalRDataSource, TId, TLoggerClient
|
|
||||||
└── [Concrete entity grid] ← consumer sets CRUD tags in constructor
|
|
||||||
```
|
|
||||||
|
|
||||||
### Companion Components
|
|
||||||
|
|
||||||
| Component | Purpose | Docs |
|
|
||||||
|---|---|---|
|
|
||||||
| **MgGridWithInfoPanel** | `DxSplitter` wrapper: grid + InfoPanel, fullscreen, splitter persistence | `MGGRID_INFOPANEL.md` |
|
|
||||||
| **MgGridToolbarBase** | `DxToolbar` base with `Grid` reference, `RefreshClick` callback | `MGGRID_TOOLBAR.md` |
|
|
||||||
| **MgGridToolbarTemplate** | Full toolbar: CRUD, navigation, layout menu, fullscreen, export | `MGGRID_TOOLBAR.md` |
|
|
||||||
| **MgGridDataColumn** | Extended `DxGridDataColumn` with InfoPanel params and `UrlLink` | `MGGRID_COLUMNS.md` |
|
|
||||||
| **MgGridInfoPanel** | Default InfoPanel: column-value pairs, edit mode, typed editors | `MGGRID_INFOPANEL.md` |
|
|
||||||
| **MgGridSignalRDataSource** | `GridCustomDataSource` wrapper: server-side filter/sort/page, local cache | `MGGRID_DATASOURCE.md` |
|
|
||||||
|
|
||||||
## Generic Type Parameters
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient>
|
|
||||||
```
|
|
||||||
|
|
||||||
| Parameter | Constraint | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `TSignalRDataSource` | `: AcSignalRDataSource<…>` | SignalR-backed data source (see `AyCode.Services.Server/docs/SIGNALR_DATASOURCE/README.md` in AyCode.Core repo) |
|
|
||||||
| `TDataItem` | `: class, IId<TId>` | Entity type displayed in the grid |
|
|
||||||
| `TId` | `: struct` | Primary key type (`int`, `Guid`) |
|
|
||||||
| `TLoggerClient` | `: AcLoggerBase` | Logger for diagnostics |
|
|
||||||
|
|
||||||
### Usage Example (Project-Specific Adapter)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Project adapter — fixes TSignalRDataSource, TId, TLoggerClient for the entire project
|
|
||||||
public class MyProjectGridBase<TDataItem>
|
|
||||||
: MgGridBase<MySignalRDataSource<TDataItem>, TDataItem, int, MyLoggerClient>
|
|
||||||
where TDataItem : class, IId<int>
|
|
||||||
{
|
|
||||||
[Inject] public required MyLoggedInModel LoggedInModel { get; set; }
|
|
||||||
protected override int GetLayoutUserId() => LoggedInModel.UserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Concrete grid — only TDataItem remains open
|
|
||||||
public class GridOrderBase : MyProjectGridBase<Order>
|
|
||||||
{
|
|
||||||
public GridOrderBase()
|
|
||||||
{
|
|
||||||
GetAllMessageTag = MySignalRTags.GetOrders;
|
|
||||||
AddMessageTag = MySignalRTags.AddOrder;
|
|
||||||
UpdateMessageTag = MySignalRTags.UpdateOrder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Interface: IMgGridBase
|
|
||||||
|
|
||||||
The public contract exposed to companion components (toolbar, InfoPanel, wrapper):
|
|
||||||
|
|
||||||
| Member | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `IsSyncing` | `bool` | Whether SignalR sync is in progress |
|
|
||||||
| `Caption` | `string` | Grid title |
|
|
||||||
| `GridEditState` | `MgGridEditState` | `None` / `New` / `Edit` |
|
|
||||||
| `ParentGrid` | `IMgGridBase?` | Parent in master-detail hierarchy |
|
|
||||||
| `GetRootGrid()` | `IMgGridBase` | Walks to topmost grid |
|
|
||||||
| `StepPrevRow()` | `void` | Navigate to previous visible row |
|
|
||||||
| `StepNextRow()` | `void` | Navigate to next visible row |
|
|
||||||
| `InfoPanelInstance` | `IInfoPanelBase?` | Resolved InfoPanel reference |
|
|
||||||
| `IsFullscreen` | `bool` | Current fullscreen state |
|
|
||||||
| `AutomaticLayoutStorageKey` | `string` | Current auto-save storage key |
|
|
||||||
| `ToggleFullscreen()` | `void` | Toggle fullscreen mode |
|
|
||||||
| `SaveUserLayoutAsync()` | `Task` | Save layout manually |
|
|
||||||
| `LoadUserLayoutAsync()` | `Task` | Load manually saved layout |
|
|
||||||
| `ResetLayoutAsync()` | `Task` | Reset to default layout |
|
|
||||||
| `HasUserLayoutAsync()` | `Task<bool>` | Check if manual save exists |
|
|
||||||
|
|
||||||
## Event Args Classes
|
|
||||||
|
|
||||||
| Class | Base | Extra Properties |
|
|
||||||
|---|---|---|
|
|
||||||
| `GridDataItemChangedEventArgs<T>` | — | `Grid`, `DataItem`, `TrackingState`, `CancelStateChangeInvoke` |
|
|
||||||
| `GridDataItemChangingEventArgs<T>` | `GridDataItemChangedEventArgs<T>` | `IsCanceled` |
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
# AyCode.Blazor.Components documentation
|
|
||||||
|
|
||||||
Topic documentation for the `AyCode.Blazor.Components` project (Blazor component library).
|
|
||||||
|
|
||||||
## Topics
|
|
||||||
|
|
||||||
- [`MGGRID/`](MGGRID/README.md) — MGGRID data grid component family (CRUD, layout, columns, toolbar, rendering, etc.)
|
|
||||||
|
|
||||||
## Navigation
|
|
||||||
|
|
||||||
Per the AI Agent Core Protocol (folder navigation rule), start from this README when browsing `docs/`. Each topic folder has its own `README.md` with the main content, plus `TOPIC_*.md` sub-topic files.
|
|
||||||
|
|
||||||
## See also
|
|
||||||
|
|
||||||
- **Blazor repo conventions**: `../../docs/CONVENTIONS.md`
|
|
||||||
- **Architecture overview**: `../../docs/ARCHITECTURE.md`
|
|
||||||
|
|
@ -1,324 +0,0 @@
|
||||||
/* MgGridInfoPanel styles - DevExpress Fluent theme compatible */
|
|
||||||
|
|
||||||
/*
|
|
||||||
DevExpress Fluent theme uses --DS-* CSS variables (Design System tokens).
|
|
||||||
These are defined on .dxbl-theme-fluent class and inherited by child elements.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* Main panel - uses DevExpress Design System variables */
|
|
||||||
.mg-grid-info-panel {
|
|
||||||
background-color: var(--DS-color-surface-neutral-subdued-rest, #f8f9fa);
|
|
||||||
color: var(--DS-color-content-neutral-default-rest, #212529);
|
|
||||||
font-family: var(--DS-font-family-sans-serif, inherit);
|
|
||||||
font-size: var(--DS-font-size-body-1, 14px);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
max-height: 100%;
|
|
||||||
border: 1px solid var(--DS-color-border-neutral-default-rest, #dee2e6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-grid-info-panel.edit-mode {
|
|
||||||
background-color: var(--DS-color-surface-warning-subdued-rest, #fffbeb);
|
|
||||||
border: 1px solid var(--DS-color-border-neutral-default-rest, #dee2e6);
|
|
||||||
border-left: 3px solid var(--DS-color-border-warning-default-rest, #f59e0b);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Container queries support - progressive enhancement */
|
|
||||||
@supports (container-type: inline-size) {
|
|
||||||
.mg-grid-info-panel {
|
|
||||||
container-type: inline-size;
|
|
||||||
container-name: infopanel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header styling */
|
|
||||||
.mg-grid-info-panel .mg-info-panel-header {
|
|
||||||
padding: var(--DS-sizing-80, 0.5rem) var(--DS-sizing-160, 1rem);
|
|
||||||
background-color: var(--DS-color-surface-neutral-default-rest, #ffffff);
|
|
||||||
border-bottom: 1px solid var(--DS-color-border-neutral-default-rest, #dee2e6);
|
|
||||||
font-weight: var(--DS-font-weight-subtitle-2, 600);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toolbar styling */
|
|
||||||
.mg-info-panel-toolbar {
|
|
||||||
padding: var(--DS-sizing-40, 0.25rem) var(--DS-sizing-80, 0.5rem);
|
|
||||||
background-color: var(--DS-color-surface-neutral-default-rest, #ffffff);
|
|
||||||
border-bottom: 1px solid var(--DS-color-border-neutral-default-rest, #dee2e6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Content area - scrollable */
|
|
||||||
.mg-info-panel-content {
|
|
||||||
flex: 1 1 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
padding: var(--DS-sizing-160, 1rem);
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grid layout for columns */
|
|
||||||
.mg-info-panel-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: var(--DS-sizing-80, 0.5rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fixed column count classes */
|
|
||||||
.mg-columns-1 .mg-info-panel-grid {
|
|
||||||
grid-template-columns: 1fr !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-columns-2 .mg-info-panel-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-columns-3 .mg-info-panel-grid {
|
|
||||||
grid-template-columns: repeat(3, 1fr) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-columns-4 .mg-info-panel-grid {
|
|
||||||
grid-template-columns: repeat(4, 1fr) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive layouts using container queries */
|
|
||||||
@container infopanel (min-width: 400px) {
|
|
||||||
.mg-grid-info-panel:not(.mg-columns-1):not(.mg-columns-2):not(.mg-columns-3):not(.mg-columns-4) .mg-info-panel-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@container infopanel (min-width: 800px) {
|
|
||||||
.mg-grid-info-panel:not(.mg-columns-1):not(.mg-columns-2):not(.mg-columns-3):not(.mg-columns-4) .mg-info-panel-grid {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@container infopanel (min-width: 1300px) {
|
|
||||||
.mg-grid-info-panel:not(.mg-columns-1):not(.mg-columns-2):not(.mg-columns-3):not(.mg-columns-4) .mg-info-panel-grid {
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grid item */
|
|
||||||
.mg-info-panel-item {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Label styling */
|
|
||||||
.mg-info-panel-label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: var(--DS-sizing-40, 0.25rem);
|
|
||||||
font-size: var(--DS-font-size-caption-1, 12px);
|
|
||||||
font-weight: var(--DS-font-weight-caption-1-strong, 600);
|
|
||||||
color: var(--DS-color-content-neutral-subdued-rest, #6c757d);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-info-panel-label.editable {
|
|
||||||
color: var(--DS-color-content-primary-default-rest, #0d6efd);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* View mode value styling */
|
|
||||||
.mg-info-panel-value {
|
|
||||||
display: block;
|
|
||||||
padding: var(--DS-sizing-40, 0.25rem) 0;
|
|
||||||
color: var(--DS-color-content-neutral-default-rest, #212529);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-info-panel-value-numeric {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-info-panel-value-date {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty state */
|
|
||||||
.mg-info-panel-empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
color: var(--DS-color-content-neutral-subdued-disabled, #adb5bd);
|
|
||||||
padding: var(--DS-sizing-240, 1.5rem);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Tables inside info panel - Default Base Styling
|
|
||||||
======================================== */
|
|
||||||
|
|
||||||
.mg-info-panel-content table {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
table-layout: fixed;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: var(--DS-font-size-body-1, 14px);
|
|
||||||
color: var(--DS-color-content-neutral-default-rest, #212529);
|
|
||||||
margin-bottom: var(--DS-sizing-160, 1rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-info-panel-content table th,
|
|
||||||
.mg-info-panel-content table td {
|
|
||||||
padding: var(--DS-sizing-40, 0.25rem) var(--DS-sizing-80, 0.5rem);
|
|
||||||
border: 1px solid var(--DS-color-border-neutral-default-rest, #dee2e6);
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-info-panel-content table th {
|
|
||||||
background-color: var(--DS-color-surface-neutral-subdued-rest, #f8f9fa);
|
|
||||||
font-weight: var(--DS-font-weight-body-1-strong, 600);
|
|
||||||
color: var(--DS-color-content-neutral-subdued-rest, #6c757d);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-info-panel-content table tbody tr:nth-child(odd) {
|
|
||||||
background-color: var(--DS-color-surface-neutral-default-rest, #ffffff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-info-panel-content table tbody tr:nth-child(even) {
|
|
||||||
background-color: var(--DS-color-surface-neutral-subdued-rest, #f8f9fa);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-info-panel-content table tbody tr:hover {
|
|
||||||
background-color: var(--DS-color-surface-neutral-default-hovered, #f5f5f5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive: make table more compact on smaller screens */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.mg-info-panel-content table {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-info-panel-content table th,
|
|
||||||
.mg-info-panel-content table td {
|
|
||||||
padding: 0.375rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Splitter pane styling - no padding/margin */
|
|
||||||
.mg-grid-with-info-panel {
|
|
||||||
height: 100%;
|
|
||||||
/* Override DevExpress splitter pane padding variables */
|
|
||||||
--dxbl-splitter-pane-padding-x: 0;
|
|
||||||
--dxbl-splitter-pane-padding-y: 0;
|
|
||||||
--dxbl-splitter-pane-padding-x-s: 0;
|
|
||||||
--dxbl-splitter-pane-padding-x-m: 0;
|
|
||||||
--dxbl-splitter-pane-padding-x-l: 0;
|
|
||||||
--dxbl-splitter-pane-padding-y-s: 0;
|
|
||||||
--dxbl-splitter-pane-padding-y-m: 0;
|
|
||||||
--dxbl-splitter-pane-padding-y-l: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-grid-with-info-panel > .dxbl-splitter-pane {
|
|
||||||
padding: 0 !important;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-info-panel-pane {
|
|
||||||
background-color: var(--DS-color-surface-neutral-subdued-rest, #f8f9fa);
|
|
||||||
padding: 0 !important;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fullscreen overlay styling (Bootstrap 5 based) */
|
|
||||||
.mg-fullscreen-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
z-index: 1050;
|
|
||||||
background-color: var(--DS-color-surface-neutral-default-rest, #ffffff);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-fullscreen-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background-color: var(--DS-color-surface-primary-default-rest, #0d6efd);
|
|
||||||
color: var(--DS-color-content-neutral-static-inverted-rest, #fff);
|
|
||||||
border-bottom: 1px solid var(--DS-color-border-neutral-default-rest, #dee2e6);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-fullscreen-title {
|
|
||||||
font-size: var(--DS-font-size-subtitle-2, 1.1rem);
|
|
||||||
font-weight: var(--DS-font-weight-subtitle-2, 600);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-fullscreen-header .btn-close-white {
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-fullscreen-header .btn-close-white:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-fullscreen-body {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-fullscreen-body .mg-grid-with-info-panel,
|
|
||||||
.mg-fullscreen-body .dxbl-grid {
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Legacy DxWindow styling (kept for backwards compatibility) */
|
|
||||||
.mg-fullscreen-window {
|
|
||||||
position: fixed !important;
|
|
||||||
top: 0 !important;
|
|
||||||
left: 0 !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-fullscreen-window .dxbl-window-body {
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-fullscreen-content {
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mg-fullscreen-content .mg-grid-with-info-panel,
|
|
||||||
.mg-fullscreen-content .dxbl-grid {
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fullscreen icon classes */
|
|
||||||
.grid-fullscreen::before {
|
|
||||||
content: "\e90c";
|
|
||||||
font-family: 'devextreme-icons';
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-fullscreen-exit::before {
|
|
||||||
content: "\e90d";
|
|
||||||
font-family: 'devextreme-icons';
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
// MgCardView - Scroll handling
|
|
||||||
window.MgCardView = {
|
|
||||||
scrollToElement: function (elementId) {
|
|
||||||
const element = document.getElementById(elementId);
|
|
||||||
if (!element) return;
|
|
||||||
|
|
||||||
// Find the closest scroll container
|
|
||||||
const scrollArea = element.closest('.mg-card-scroll-area');
|
|
||||||
if (scrollArea) {
|
|
||||||
const containerRect = scrollArea.getBoundingClientRect();
|
|
||||||
const elementRect = element.getBoundingClientRect();
|
|
||||||
const offset = elementRect.top - containerRect.top - (containerRect.height - elementRect.height) / 2;
|
|
||||||
scrollArea.scrollBy({ top: offset, behavior: 'smooth' });
|
|
||||||
} else {
|
|
||||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
// MgGridInfoPanel - Sticky scroll handling
|
|
||||||
// Makes the InfoPanel sticky to viewport when scrolling
|
|
||||||
|
|
||||||
window.MgGridInfoPanel = {
|
|
||||||
observers: new Map(),
|
|
||||||
|
|
||||||
// Initialize sticky behavior for an InfoPanel element
|
|
||||||
initSticky: function (element, topOffset) {
|
|
||||||
if (!element) return;
|
|
||||||
|
|
||||||
const elementId = element.id || this.generateId(element);
|
|
||||||
|
|
||||||
// Clean up existing observer if any
|
|
||||||
this.disposeSticky(element);
|
|
||||||
|
|
||||||
// Store the initial position of the element (relative to document)
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
const initialTop = rect.top + window.scrollY;
|
|
||||||
|
|
||||||
// Calculate and set initial state
|
|
||||||
this.updatePosition(element, initialTop);
|
|
||||||
|
|
||||||
// Handler to update position on scroll and resize
|
|
||||||
const updateHandler = () => {
|
|
||||||
this.updatePosition(element, initialTop);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add event listeners - use passive to not block scrolling
|
|
||||||
window.addEventListener('resize', updateHandler, { passive: true });
|
|
||||||
window.addEventListener('scroll', updateHandler, { passive: true });
|
|
||||||
|
|
||||||
// Store cleanup info
|
|
||||||
this.observers.set(elementId, {
|
|
||||||
element: element,
|
|
||||||
updateHandler: updateHandler,
|
|
||||||
initialTop: initialTop
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Dispose sticky behavior
|
|
||||||
disposeSticky: function (element) {
|
|
||||||
if (!element) return;
|
|
||||||
|
|
||||||
const elementId = element.id || this.findElementId(element);
|
|
||||||
const observer = this.observers.get(elementId);
|
|
||||||
|
|
||||||
if (observer) {
|
|
||||||
window.removeEventListener('resize', observer.updateHandler);
|
|
||||||
window.removeEventListener('scroll', observer.updateHandler);
|
|
||||||
|
|
||||||
// Reset styles
|
|
||||||
element.style.height = '';
|
|
||||||
element.style.maxHeight = '';
|
|
||||||
element.style.transform = '';
|
|
||||||
|
|
||||||
this.observers.delete(elementId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Update panel position and height based on scroll
|
|
||||||
updatePosition: function (element, initialTop) {
|
|
||||||
if (!element) return;
|
|
||||||
|
|
||||||
const scrollY = window.scrollY;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
const bottomPadding = 30; // 30px from bottom
|
|
||||||
|
|
||||||
// Calculate how much we've scrolled past the initial position
|
|
||||||
const scrolledPast = Math.max(0, scrollY - initialTop);
|
|
||||||
|
|
||||||
// Get the splitter pane to know our container limits
|
|
||||||
const paneContainer = element.closest('.dxbl-splitter-pane');
|
|
||||||
let maxScrollOffset = Infinity;
|
|
||||||
|
|
||||||
if (paneContainer) {
|
|
||||||
// Don't scroll past the bottom of the pane
|
|
||||||
const paneHeight = paneContainer.offsetHeight;
|
|
||||||
const elementHeight = element.offsetHeight;
|
|
||||||
maxScrollOffset = Math.max(0, paneHeight - elementHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp the scroll offset
|
|
||||||
const translateY = Math.min(scrolledPast, maxScrollOffset);
|
|
||||||
|
|
||||||
// Apply transform to make it "sticky"
|
|
||||||
element.style.transform = `translateY(${translateY}px)`;
|
|
||||||
|
|
||||||
// Calculate height: from current visual position to viewport bottom
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
const visualTop = rect.top; // This already accounts for transform
|
|
||||||
|
|
||||||
// Height from current visual top to viewport bottom minus padding
|
|
||||||
const availableHeight = viewportHeight - visualTop - bottomPadding;
|
|
||||||
|
|
||||||
// Clamp height
|
|
||||||
const finalHeight = Math.max(200, Math.min(availableHeight, viewportHeight - bottomPadding));
|
|
||||||
|
|
||||||
element.style.height = finalHeight + 'px';
|
|
||||||
element.style.maxHeight = finalHeight + 'px';
|
|
||||||
},
|
|
||||||
|
|
||||||
// Generate a unique ID for the element
|
|
||||||
generateId: function (element) {
|
|
||||||
const id = 'mg-info-panel-' + Math.random().toString(36).substr(2, 9);
|
|
||||||
element.id = id;
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Find element ID from stored observers
|
|
||||||
findElementId: function (element) {
|
|
||||||
for (const [id, observer] of this.observers.entries()) {
|
|
||||||
if (observer.element === element) {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,64 +1,64 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<RunAOTCompilation>true</RunAOTCompilation>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<WasmStripILAfterAOT>true</WasmStripILAfterAOT>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<Import Project="..//AyCode.Blazor.targets" />
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\AyCode.Blazor.Models.Server\AyCode.Blazor.Models.Server.csproj" />
|
||||||
|
<ProjectReference Include="..\AyCode.Blazor.Models\AyCode.Blazor.Models.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\AyCode.Blazor.Models.Server\AyCode.Blazor.Models.Server.csproj" />
|
<Reference Include="AyCode.Core">
|
||||||
<ProjectReference Include="..\AyCode.Blazor.Models\AyCode.Blazor.Models.csproj" />
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.dll</HintPath>
|
||||||
</ItemGroup>
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Core.Server">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.Server.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Database">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Database.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Entities">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Entities.Server">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.Server.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Interfaces">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Interfaces.Server">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.Server.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Models">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Models.Server">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.Server.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Services">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Services.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Services.Server">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Services.Server.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="AyCode.Utils">
|
||||||
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Utils.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="AyCode.Core">
|
<Folder Include="Loggers\" />
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.dll</HintPath>
|
</ItemGroup>
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Core.Server">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.Server.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Database">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Database.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Entities">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Entities.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Entities.Server">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Entities.Server.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Interfaces">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Interfaces.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Interfaces.Server">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Interfaces.Server.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Models">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Models.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Models.Server">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Models.Server.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Services">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Services.Server">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.Server.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Utils">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Utils.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Loggers\" />
|
<PackageReference Include="MessagePack" Version="2.5.187" />
|
||||||
</ItemGroup>
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.10" />
|
||||||
<ItemGroup>
|
<PackageReference Include="Serialize.Linq" Version="3.1.160" />
|
||||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
</ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.11" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.11" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
# AyCode.Blazor.Controllers
|
|
||||||
|
|
||||||
@project {
|
|
||||||
type = "framework"
|
|
||||||
own-dep-projects = [
|
|
||||||
"AyCode.Core, AyCode.Core.Server, AyCode.Database, AyCode.Entities, AyCode.Entities.Server, AyCode.Interfaces, AyCode.Interfaces.Server, AyCode.Models, AyCode.Models.Server, AyCode.Services, AyCode.Services.Server, AyCode.Utils (in AyCode.Core repo)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffolding project for Blazor controllers and logging infrastructure. Currently contains only a placeholder class with a reserved folder for future logger implementations.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
- **`Class1.cs`** -- Empty placeholder class in the `AyCode.Blazor.Controllers` namespace.
|
|
||||||
|
|
||||||
## Reserved Folders
|
|
||||||
- **`Loggers/`** -- Future logger implementations.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
| Dependency | Version | Type |
|
|
||||||
|---|---|---|
|
|
||||||
| AyCode.Blazor.Models | -- | ProjectReference |
|
|
||||||
| AyCode.Blazor.Models.Server | -- | ProjectReference |
|
|
||||||
| MessagePack | 3.1.4 | NuGet |
|
|
||||||
| Microsoft.AspNetCore.SignalR.Client | 9.0.11 | NuGet |
|
|
||||||
| Microsoft.AspNetCore.SignalR.Common | 9.0.11 | NuGet |
|
|
||||||
| AyCode.Core, .Core.Server, .Database, .Entities, .Entities.Server, .Interfaces, .Interfaces.Server, .Models, .Models.Server, .Services, .Services.Server, .Utils | -- | DLL references |
|
|
||||||
|
|
@ -1,51 +1,42 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<RunAOTCompilation>true</RunAOTCompilation>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<WasmStripILAfterAOT>true</WasmStripILAfterAOT>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<Import Project="..//AyCode.Blazor.targets" />
|
<ItemGroup>
|
||||||
|
<Reference Include="AyCode.Core">
|
||||||
<ItemGroup>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.dll</HintPath>
|
||||||
<Reference Include="AyCode.Core">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.dll</HintPath>
|
<Reference Include="AyCode.Core.Server">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.Server.dll</HintPath>
|
||||||
<Reference Include="AyCode.Core.Server">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.Server.dll</HintPath>
|
<Reference Include="AyCode.Database">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Database.dll</HintPath>
|
||||||
<Reference Include="AyCode.Database">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Database.dll</HintPath>
|
<Reference Include="AyCode.Entities">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.dll</HintPath>
|
||||||
<Reference Include="AyCode.Entities">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Entities.dll</HintPath>
|
<Reference Include="AyCode.Entities.Server">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.Server.dll</HintPath>
|
||||||
<Reference Include="AyCode.Entities.Server">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Entities.Server.dll</HintPath>
|
<Reference Include="AyCode.Interfaces">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.dll</HintPath>
|
||||||
<Reference Include="AyCode.Interfaces">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Interfaces.dll</HintPath>
|
<Reference Include="AyCode.Interfaces.Server">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.Server.dll</HintPath>
|
||||||
<Reference Include="AyCode.Interfaces.Server">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Interfaces.Server.dll</HintPath>
|
<Reference Include="AyCode.Models.Server">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.Server.dll</HintPath>
|
||||||
<Reference Include="AyCode.Models.Server">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Models.Server.dll</HintPath>
|
<Reference Include="AyCode.Services.Server">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Services.Server.dll</HintPath>
|
||||||
<Reference Include="AyCode.Services">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.dll</HintPath>
|
<Reference Include="AyCode.Utils">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Utils.dll</HintPath>
|
||||||
<Reference Include="AyCode.Services.Server">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.Server.dll</HintPath>
|
</ItemGroup>
|
||||||
</Reference>
|
|
||||||
<Reference Include="AyCode.Utils">
|
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Utils.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="Models\" />
|
|
||||||
<Folder Include="Services\" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
# AyCode.Blazor.Models.Server
|
|
||||||
|
|
||||||
@project {
|
|
||||||
type = "framework"
|
|
||||||
own-dep-projects = [
|
|
||||||
"AyCode.Core, AyCode.Core.Server, AyCode.Database, AyCode.Entities, AyCode.Entities.Server, AyCode.Interfaces, AyCode.Interfaces.Server, AyCode.Models.Server, AyCode.Services, AyCode.Services.Server, AyCode.Utils (in AyCode.Core repo)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffolding project for server-side Blazor models and services. Currently contains only a placeholder class with reserved folders for future implementation.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
- **`Class1.cs`** -- Empty placeholder class in the `AyCode.Blazor.Models.Server` namespace.
|
|
||||||
|
|
||||||
## Reserved Folders
|
|
||||||
- **`Models/`** -- Future server-side model definitions.
|
|
||||||
- **`Services/`** -- Future server-side service implementations.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
| Dependency | Type |
|
|
||||||
|---|---|
|
|
||||||
| AyCode.Core | DLL reference |
|
|
||||||
| AyCode.Core.Server | DLL reference |
|
|
||||||
| AyCode.Database | DLL reference |
|
|
||||||
| AyCode.Entities | DLL reference |
|
|
||||||
| AyCode.Entities.Server | DLL reference |
|
|
||||||
| AyCode.Interfaces | DLL reference |
|
|
||||||
| AyCode.Interfaces.Server | DLL reference |
|
|
||||||
| AyCode.Models.Server | DLL reference |
|
|
||||||
| AyCode.Services | DLL reference |
|
|
||||||
| AyCode.Services.Server | DLL reference |
|
|
||||||
| AyCode.Utils | DLL reference |
|
|
||||||
|
|
@ -1,46 +1,45 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<RunAOTCompilation>true</RunAOTCompilation>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<WasmStripILAfterAOT>true</WasmStripILAfterAOT>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<Import Project="..//AyCode.Blazor.targets" />
|
<ItemGroup>
|
||||||
|
<Reference Include="AyCode.Core">
|
||||||
<ItemGroup>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.dll</HintPath>
|
||||||
<Reference Include="AyCode.Core">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.dll</HintPath>
|
<Reference Include="AyCode.Core.Server">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.Server.dll</HintPath>
|
||||||
<Reference Include="AyCode.Core.Server">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.Server.dll</HintPath>
|
<Reference Include="AyCode.Entities">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.dll</HintPath>
|
||||||
<Reference Include="AyCode.Entities">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Entities.dll</HintPath>
|
<Reference Include="AyCode.Entities.Server">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.Server.dll</HintPath>
|
||||||
<Reference Include="AyCode.Entities.Server">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Entities.Server.dll</HintPath>
|
<Reference Include="AyCode.Interfaces">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.dll</HintPath>
|
||||||
<Reference Include="AyCode.Interfaces">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Interfaces.dll</HintPath>
|
<Reference Include="AyCode.Interfaces.Server">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.Server.dll</HintPath>
|
||||||
<Reference Include="AyCode.Interfaces.Server">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Interfaces.Server.dll</HintPath>
|
<Reference Include="AyCode.Models">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.dll</HintPath>
|
||||||
<Reference Include="AyCode.Models">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Models.dll</HintPath>
|
<Reference Include="AyCode.Models.Server">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.Server.dll</HintPath>
|
||||||
<Reference Include="AyCode.Models.Server">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Models.Server.dll</HintPath>
|
<Reference Include="AyCode.Services">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Services.dll</HintPath>
|
||||||
<Reference Include="AyCode.Services">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.dll</HintPath>
|
<Reference Include="AyCode.Services.Server">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Services.Server.dll</HintPath>
|
||||||
<Reference Include="AyCode.Services.Server">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.Server.dll</HintPath>
|
<Reference Include="AyCode.Utils">
|
||||||
</Reference>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Utils.dll</HintPath>
|
||||||
<Reference Include="AyCode.Utils">
|
</Reference>
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Utils.dll</HintPath>
|
</ItemGroup>
|
||||||
</Reference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
# AyCode.Blazor.Models
|
|
||||||
|
|
||||||
@project {
|
|
||||||
type = "framework"
|
|
||||||
own-dep-projects = [
|
|
||||||
"AyCode.Core, AyCode.Core.Server, AyCode.Entities, AyCode.Entities.Server, AyCode.Interfaces, AyCode.Interfaces.Server, AyCode.Models, AyCode.Models.Server, AyCode.Services, AyCode.Services.Server, AyCode.Utils (in AyCode.Core repo)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
.NET 10 class library with AOT compilation (`RunAOTCompilation` + `WasmStripILAfterAOT`) providing view model base classes and shared models for the AyCode Blazor UI layer.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
- **`Class1.cs`** -- Empty placeholder class in the root namespace.
|
|
||||||
- **`ViewModels/`** -- Abstract view model base classes (see [ViewModels/README.md](ViewModels/README.md)).
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
All referenced as pre-built DLLs from `AyCode.Core\AyCode.Services.Server\bin\`:
|
|
||||||
|
|
||||||
| Assembly | Type |
|
|
||||||
|---|---|
|
|
||||||
| AyCode.Core | DLL reference |
|
|
||||||
| AyCode.Core.Server | DLL reference |
|
|
||||||
| AyCode.Entities | DLL reference |
|
|
||||||
| AyCode.Entities.Server | DLL reference |
|
|
||||||
| AyCode.Interfaces | DLL reference |
|
|
||||||
| AyCode.Interfaces.Server | DLL reference |
|
|
||||||
| AyCode.Models | DLL reference |
|
|
||||||
| AyCode.Models.Server | DLL reference |
|
|
||||||
| AyCode.Services | DLL reference |
|
|
||||||
| AyCode.Services.Server | DLL reference |
|
|
||||||
| AyCode.Utils | DLL reference |
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
# ViewModels
|
|
||||||
|
|
||||||
Abstract view model base classes for the AyCode Blazor UI. All classes are abstract and currently contain no members, serving as the foundation for concrete view models in downstream projects.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
- **`AcViewModelBase.cs`** -- Root abstract base class for all view models.
|
|
||||||
- **`AcDomainViewModel.cs`** -- Abstract base for domain-level view models (standalone, does not extend `AcViewModelBase`).
|
|
||||||
- **`AcGridViewModelBase.cs`** -- Abstract base for grid/table view models; extends `AcViewModelBase`.
|
|
||||||
- **`AcPageViewModelBase.cs`** -- Abstract base for page-level view models; extends `AcViewModelBase`.
|
|
||||||
- **`AcSiteViewModel.cs`** -- Abstract base for site-wide view models; extends `AcDomainViewModel`.
|
|
||||||
|
|
||||||
## Inheritance
|
|
||||||
|
|
||||||
```
|
|
||||||
AcViewModelBase
|
|
||||||
+-- AcGridViewModelBase
|
|
||||||
+-- AcPageViewModelBase
|
|
||||||
|
|
||||||
AcDomainViewModel
|
|
||||||
+-- AcSiteViewModel
|
|
||||||
```
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
|
||||||
|
|
||||||
<Import Project="AyCode.Project.targets" />
|
|
||||||
<PropertyGroup>
|
|
||||||
<Name>AyCode.Blazor.targets</Name>
|
|
||||||
|
|
||||||
<TargetFramework>$(_TargetFramework)</TargetFramework>
|
|
||||||
</PropertyGroup>
|
|
||||||
</Project>
|
|
||||||
|
|
@ -1,56 +1,47 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||||
|
|
||||||
<Import Project="..//AyCode.Maui.targets" />
|
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFrameworks>net10.0-android;net10.0-ios</TargetFrameworks>
|
<TargetFrameworks>net8.0;net8.0-maccatalyst;net8.0-ios;net8.0-android34.0</TargetFrameworks>
|
||||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.26100.0</TargetFrameworks>
|
|
||||||
|
|
||||||
<!-- Uncomment to also build the tizen app. You will need to install tizen by following this: https://github.com/Samsung/Tizen.NET -->
|
<!-- Uncomment to also build the tizen app. You will need to install tizen by following this: https://github.com/Samsung/Tizen.NET -->
|
||||||
<!-- <TargetFrameworks>$(TargetFrameworks);net9.0-tizen</TargetFrameworks> -->
|
<!-- <TargetFrameworks>$(TargetFrameworks);net8.0-tizen</TargetFrameworks> -->
|
||||||
<UseMaui>true</UseMaui>
|
<UseMaui>true</UseMaui>
|
||||||
<SingleProject>true</SingleProject>
|
<SingleProject>true</SingleProject>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
|
||||||
<RunAOTCompilation>true</RunAOTCompilation>
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">14.2</SupportedOSPlatformVersion>
|
||||||
<WasmStripILAfterAOT>true</WasmStripILAfterAOT>
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">14.0</SupportedOSPlatformVersion>
|
||||||
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">29.0</SupportedOSPlatformVersion>
|
||||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
|
||||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
|
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
|
||||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">33.0</SupportedOSPlatformVersion>
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion>
|
||||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.19041.0</SupportedOSPlatformVersion>
|
|
||||||
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.19041.0</TargetPlatformMinVersion>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
<PackageReference Include="MessagePack" Version="2.5.187" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.10" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.10" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="AyCode.Core">
|
<Reference Include="AyCode.Core">
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.dll</HintPath>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.dll</HintPath>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="AyCode.Entities">
|
<Reference Include="AyCode.Entities">
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Entities.dll</HintPath>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.dll</HintPath>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="AyCode.Interfaces">
|
<Reference Include="AyCode.Interfaces">
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Interfaces.dll</HintPath>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.dll</HintPath>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="AyCode.Models">
|
<Reference Include="AyCode.Models">
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Models.dll</HintPath>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.dll</HintPath>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="AyCode.Services">
|
<Reference Include="AyCode.Services">
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.dll</HintPath>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Services.dll</HintPath>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="AyCode.Utils">
|
<Reference Include="AyCode.Utils">
|
||||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Utils.dll</HintPath>
|
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Utils.dll</HintPath>
|
||||||
</Reference>
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.11" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.11" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
# Platforms
|
|
||||||
|
|
||||||
Platform-specific code for AyCode.Maui.Core. Each subfolder is conditionally compiled only for its target platform. All classes currently contain empty `PlatformClass1` placeholders.
|
|
||||||
|
|
||||||
## Subfolders
|
|
||||||
- **`Android/`** -- Android-specific code (API 33+).
|
|
||||||
- **`iOS/`** -- iOS-specific code (15.0+).
|
|
||||||
- **`MacCatalyst/`** -- Mac Catalyst-specific code (15.0+).
|
|
||||||
- **`Windows/`** -- Windows-specific code (10.0.19041+).
|
|
||||||
- **`Tizen/`** -- Tizen-specific code (currently commented out in the .csproj).
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
- **`{Platform}/PlatformClass1.cs`** -- Empty placeholder class per platform, all in the `AyCode.Maui.Core` namespace.
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
# AyCode.Maui.Core
|
|
||||||
|
|
||||||
@project {
|
|
||||||
type = "framework"
|
|
||||||
own-dep-projects = [
|
|
||||||
"AyCode.Core, AyCode.Entities, AyCode.Interfaces, AyCode.Models, AyCode.Services, AyCode.Utils (in AyCode.Core repo)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
.NET MAUI class library targeting Android (API 33+), iOS (15.0+), and Windows (10.0.19041+). Provides cross-platform shared code with per-platform specialization via the `Platforms/` folder.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
- **`Class1.cs`** -- Shared placeholder class included on all platforms.
|
|
||||||
- **`Platforms/`** -- Platform-specific code (see [Platforms/README.md](Platforms/README.md)).
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
| Dependency | Version | Type |
|
|
||||||
|---|---|---|
|
|
||||||
| Microsoft.Maui.Controls | 10.0.11 | NuGet |
|
|
||||||
| Microsoft.AspNetCore.Components.WebView.Maui | 10.0.11 | NuGet |
|
|
||||||
| Microsoft.AspNetCore.SignalR.Client | 9.0.11 | NuGet |
|
|
||||||
| Microsoft.AspNetCore.SignalR.Common | 9.0.11 | NuGet |
|
|
||||||
| MessagePack | 3.1.4 | NuGet |
|
|
||||||
| AyCode.Core, .Entities, .Interfaces, .Models, .Services, .Utils | -- | DLL references |
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
|
||||||
|
|
||||||
<Import Project="AyCode.Project.targets" />
|
|
||||||
<PropertyGroup>
|
|
||||||
<Name>AyCode.Maui.targets</Name>
|
|
||||||
</PropertyGroup>
|
|
||||||
</Project>
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
|
||||||
<PropertyGroup>
|
|
||||||
<Name>AyCode.Project.targets</Name>
|
|
||||||
|
|
||||||
<_ProjectName>FruitBank</_ProjectName>
|
|
||||||
<_TargetFramework>net10.0</_TargetFramework>
|
|
||||||
<_TargetFrameworkAyCodeCore>net9.0</_TargetFrameworkAyCodeCore>
|
|
||||||
|
|
||||||
<!--<GitBranch>$([System.IO.File]::ReadAlltext('$(MsBuildThisFileDirectory)\.git\HEAD').Replace('ref: refs/heads/', '').Trim())</GitBranch>
|
|
||||||
<_ProjectName>$(GitBranch)</_ProjectName>-->
|
|
||||||
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<Configurations>Debug;Release;Product</Configurations>
|
|
||||||
|
|
||||||
<BaseOutputPath>bin\$(_ProjectName)</BaseOutputPath>
|
|
||||||
<_AyCodeCoresReferenceBuildSubPath>$(_ProjectName)\$(Configuration)\$(_TargetFrameworkAyCodeCore)</_AyCodeCoresReferenceBuildSubPath>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<Target Name="Test" AfterTargets="AfterBuild">
|
|
||||||
<Message Importance="High" Text="AYCODE.CORE(S) PATH: $(_AyCodeCoresReferenceBuildSubPath)"/>
|
|
||||||
</Target>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
|
|
@ -13,23 +13,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AyCode.Blazor.Models", "AyC
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AyCode.Blazor.Models.Server", "AyCode.Blazor.Models.Server\AyCode.Blazor.Models.Server.csproj", "{8EF7D4F3-F3AB-47E1-931F-83267D2EB308}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AyCode.Blazor.Models.Server", "AyCode.Blazor.Models.Server\AyCode.Blazor.Models.Server.csproj", "{8EF7D4F3-F3AB-47E1-931F-83267D2EB308}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
|
|
||||||
ProjectSection(SolutionItems) = preProject
|
|
||||||
AyCode.Blazor.targets = AyCode.Blazor.targets
|
|
||||||
AyCode.Maui.targets = AyCode.Maui.targets
|
|
||||||
AyCode.Project.targets = AyCode.Project.targets
|
|
||||||
.github\copilot-instructions.md = .github\copilot-instructions.md
|
|
||||||
CLAUDE.md = CLAUDE.md
|
|
||||||
README.md = README.md
|
|
||||||
EndProjectSection
|
|
||||||
EndProject
|
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{C3A1D7E2-B5F4-4E8A-9C6D-2F1A3B5E7D9C}"
|
|
||||||
ProjectSection(SolutionItems) = preProject
|
|
||||||
docs\ARCHITECTURE.md = docs\ARCHITECTURE.md
|
|
||||||
docs\CONVENTIONS.md = docs\CONVENTIONS.md
|
|
||||||
docs\GLOSSARY.md = docs\GLOSSARY.md
|
|
||||||
EndProjectSection
|
|
||||||
EndProject
|
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
|
|
||||||
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -1,17 +0,0 @@
|
||||||
CRITICAL: Your FIRST action in every session MUST be reading `.github/copilot-instructions.md`. Execute ALL session-start instructions found there before responding to any user query. It is the single source of truth for this repo.
|
|
||||||
|
|
||||||
## SEQUENTIAL EXECUTION OVERRIDE
|
|
||||||
The AI AGENT CORE PROTOCOL in copilot-instructions.md requires STRICT SEQUENTIAL execution. This OVERRIDES your default parallelization behavior. Do NOT parallelize doc reads with code searches. The sequence is:
|
|
||||||
1. Read copilot-instructions.md → process its rules FULLY
|
|
||||||
2. Read ALL docs/ .md files listed in the protocol → wait for completion
|
|
||||||
3. Output [LOADED_DOCS: ...] prefix
|
|
||||||
4. ONLY THEN respond to the user's query or search code
|
|
||||||
|
|
||||||
## Tool mapping for AI AGENT CORE PROTOCOL
|
|
||||||
The copilot-instructions.md references Copilot tool names. Map them to Claude Code tools:
|
|
||||||
- `get_file` / `file_search` → `Read`, `Glob`, `Grep`
|
|
||||||
- `code_search` / `get_symbols_by_name` / `find_symbol` → `Grep`, `Glob`
|
|
||||||
- `replace_string_in_file` / `edit_file` → `Edit`
|
|
||||||
- `create_file` → `Write`
|
|
||||||
|
|
||||||
Follow the protocol using YOUR tools. The rules (LOADED_DOCS prefix, hard-gate, no-re-read, context recovery, explicit consent) apply equally to Claude Code.
|
|
||||||
45
README.md
45
README.md
|
|
@ -1,45 +0,0 @@
|
||||||
# AyCode.Blazor Solution
|
|
||||||
|
|
||||||
Blazor Server + MAUI Hybrid UI framework built on DevExpress 25.1.3 components. Targets **net10.0** (AyCode.Project.targets). Provides reusable grids, card views, authentication, and SignalR-powered real-time data. Depends on AyCode.Core (net9.0) for serialization, entities, and services — referenced via DLL.
|
|
||||||
|
|
||||||
## LLM Context
|
|
||||||
|
|
||||||
Domain rules and UI pitfalls live in a single file: `.github/copilot-instructions.md`
|
|
||||||
|
|
||||||
| Tool | Auto-loaded | Action needed |
|
|
||||||
|------|------------|---------------|
|
|
||||||
| GitHub Copilot | ✅ `copilot-instructions.md` | None |
|
|
||||||
| Claude Code | ✅ `CLAUDE.md` → references above | None |
|
|
||||||
| Cursor / Windsurf | ✅ `README.md` | Read `copilot-instructions.md` via @file |
|
|
||||||
|
|
||||||
Solution-level docs in `docs/`:
|
|
||||||
|
|
||||||
| Document | Topic |
|
|
||||||
|---|---|
|
|
||||||
| `GLOSSARY.md` | Blazor/MAUI terminology |
|
|
||||||
| `ARCHITECTURE.md` | Solution layers, dependency rules |
|
|
||||||
| `CONVENTIONS.md` | Coding conventions |
|
|
||||||
|
|
||||||
Project-level docs:
|
|
||||||
|
|
||||||
| Project | Documents |
|
|
||||||
|---|---|
|
|
||||||
| `AyCode.Blazor.Components/docs/` | `MGGRID/` — MgGrid system (grid base, toolbar, InfoPanel, layout, CRUD) |
|
|
||||||
|
|
||||||
Core framework rules: `../AyCode.Core/.github/copilot-instructions.md`
|
|
||||||
|
|
||||||
## Solution Structure
|
|
||||||
|
|
||||||
| Project | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `AyCode.Blazor.Components` | DevExpress UI components, grids, SignalR data sources, expression helpers |
|
|
||||||
| `AyCode.Blazor.Models` | Shared view models for Blazor components |
|
|
||||||
| `AyCode.Blazor.Models.Server` | Server-side model scaffolding |
|
|
||||||
| `AyCode.Blazor.Controllers` | Controller scaffolding (minimal) |
|
|
||||||
| `AyCode.Maui.Core` | MAUI cross-platform: Android, iOS, Windows |
|
|
||||||
|
|
||||||
### Test Projects
|
|
||||||
|
|
||||||
| Project | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `AyCode.Blazor.Components.Tests` | Grid and component tests |
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
# Architecture
|
|
||||||
|
|
||||||
## Framework vs. Consumer Boundary
|
|
||||||
|
|
||||||
This is **Layer 1 — UI framework**, building on Layer 0 (AyCode.Core) and consumed by Layer 2/3 projects. Full doctrine: `../AyCode.Core/docs/ARCHITECTURE.md#framework-vs-consumer-boundary`.
|
|
||||||
|
|
||||||
### Blazor/MAUI-specific notes
|
|
||||||
|
|
||||||
- Components use **generic type parameters** for consumer types (e.g. the MgGrid generic hierarchy)
|
|
||||||
- DevExpress wrappers stay generic — no consumer-entity specialization in framework components
|
|
||||||
- MAUI platform folders (`AyCode.Maui.Core/Platforms/`) provide platform abstractions only; consumer-app manifest / splash screens / app-specific assets belong in the consumer app
|
|
||||||
- UI patterns maximize **generic base + consumer derives**
|
|
||||||
|
|
||||||
## Dependency Graph
|
|
||||||
|
|
||||||
```
|
|
||||||
AyCode.Core Solution ../AyCode.Core/AyCode.Core.sln (DLL references)
|
|
||||||
↑
|
|
||||||
AyCode.Blazor.Models ← AyCode.Blazor.Models.Server
|
|
||||||
↑
|
|
||||||
AyCode.Blazor.Components ← AyCode.Blazor.Controllers
|
|
||||||
↑
|
|
||||||
AyCode.Maui.Core (MAUI Hybrid host)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rule:** UI projects reference AyCode.Core via DLL (not ProjectReference). This separates build graphs.
|
|
||||||
**Context:** When a core type is not found in this solution, browse `../AyCode.Core/` for its definition.
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### Blazor Server
|
|
||||||
1. **AyCode.Blazor.Components** provides all UI components (grids, card views, forms)
|
|
||||||
2. Components use **AcSignalRDataSource** to communicate with server via SignalR
|
|
||||||
3. SignalR uses **AcBinaryHubProtocol** for high-performance binary serialization
|
|
||||||
4. Grid filters are serialized as **AcExpressionNode** trees
|
|
||||||
|
|
||||||
### MAUI Hybrid
|
|
||||||
1. **AyCode.Maui.Core** hosts Blazor components in a WebView
|
|
||||||
2. Same components, same SignalR connection — different host
|
|
||||||
3. Platform-specific code in `Platforms/` folders (Android, iOS, Windows)
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
User → DxGrid → AcSignalRDataSource → SignalR (AcBinary) → Server Hub → DAL → Database
|
|
||||||
↓
|
|
||||||
User ← DxGrid ← AcSignalRDataSource ← SignalR (AcBinary) ← Server Hub
|
|
||||||
```
|
|
||||||
|
|
||||||
## MgGrid Component System
|
|
||||||
|
|
||||||
The primary UI pattern for data screens. Overview and index: `AyCode.Blazor.Components/docs/MGGRID/README.md`
|
|
||||||
|
|
||||||
```
|
|
||||||
DxGrid (DevExpress)
|
|
||||||
└── MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient>
|
|
||||||
└── [Project adapter, e.g. FruitBankGridBase<TDataItem>]
|
|
||||||
└── [Concrete grid, e.g. GridShippingBase]
|
|
||||||
```
|
|
||||||
|
|
||||||
| Component | Role |
|
|
||||||
|---|---|
|
|
||||||
| **MgGridBase** | Abstract base — SignalR CRUD, layout persistence, master-detail, edit state |
|
|
||||||
| **MgGridWithInfoPanel** | `DxSplitter` wrapper — grid + collapsible InfoPanel + fullscreen |
|
|
||||||
| **MgGridToolbarTemplate** | Full toolbar: CRUD, navigation, layout, export, fullscreen |
|
|
||||||
| **MgGridDataColumn** | `DxGridDataColumn` with InfoPanel parameters and URL template support |
|
|
||||||
| **MgGridInfoPanel** | Default InfoPanel — column-value display with edit mode |
|
|
||||||
| **MgGridSignalRDataSource** | `GridCustomDataSource` with local cache and background refresh |
|
|
||||||
|
|
||||||
## Key Design Decisions
|
|
||||||
|
|
||||||
- **DevExpress 25.1.3** exclusively — no mixing with other component libraries
|
|
||||||
- **SignalR over REST** for grid data — enables real-time updates and binary protocol
|
|
||||||
- **Expression serialization** — grid filters evaluated server-side, not client-side
|
|
||||||
- **Shared components** — Blazor Server and MAUI Hybrid use the exact same component library
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
# Conventions
|
|
||||||
|
|
||||||
For core framework conventions (Ac prefix, Session/Transaction pattern, etc.) see `AyCode.Core/docs/CONVENTIONS.md`.
|
|
||||||
|
|
||||||
## Framework-First Placement
|
|
||||||
|
|
||||||
Follow the doctrine in `../AyCode.Core/docs/CONVENTIONS.md#framework-first-placement`. Same verdicts and hard rules apply.
|
|
||||||
|
|
||||||
**Blazor/MAUI-specific additions:**
|
|
||||||
- UI types follow the generic-base pattern — consumer types are type parameters, not hardcoded concrete types
|
|
||||||
- DevExpress-wrapper components stay generic across consumers
|
|
||||||
- MAUI platform code = platform abstractions only, not consumer-app specifics
|
|
||||||
|
|
||||||
## Naming
|
|
||||||
|
|
||||||
- **Grid components:** `Grid{Entity}Base` (e.g., `GridPartnerBase`, `GridShippingBase`). Suffix `Base` because consuming projects may extend them.
|
|
||||||
- **CardView components:** Inside `Components/CardViews/` — card-style wrappers around DxGrid for mobile-friendly layouts.
|
|
||||||
- **Services:** `Ac{Domain}ServiceBase` for abstract bases. Platform-specific implementations in consuming projects.
|
|
||||||
- **ViewModels:** In `AyCode.Blazor.Models/ViewModels/` — suffixed with `ViewModel` or kept as plain model classes.
|
|
||||||
|
|
||||||
## XML Documentation
|
|
||||||
|
|
||||||
`<summary>` — brief, developer-facing, readable in VS IntelliSense tooltip. NO implementation details, NO wire-format / byte-level / perf specifics — those live in `docs/TOPIC/*.md`. Add `<example>` only when usage is non-obvious; otherwise omit.
|
|
||||||
|
|
||||||
## Component Patterns
|
|
||||||
|
|
||||||
- **DevExpress 25.1.3 exclusively** — never mix with other Blazor component libraries.
|
|
||||||
- **DxGrid + AcSignalRDataSource** — grids always bind to SignalR-backed data sources, not REST.
|
|
||||||
- **AcExpressionNode** for grid filters — LINQ expressions are serialized client-side and evaluated server-side.
|
|
||||||
- **Layout persistence** — grid column order/width saved to localStorage via `MgGridBase`.
|
|
||||||
- **ExpressionHelper** services handle AcLinq ↔ DevExpress filter conversion.
|
|
||||||
|
|
||||||
## MAUI Patterns
|
|
||||||
|
|
||||||
- **Platform folders** (`Platforms/Android/`, `iOS/`, `Windows/`) for platform-specific code.
|
|
||||||
- **BlazorWebView** hosts the same Blazor components used in Server mode.
|
|
||||||
- **IFormFactor** interface — each platform provides its own implementation (Phone, Tablet, Desktop, Web, WebAssembly).
|
|
||||||
|
|
||||||
## Blazor Patterns
|
|
||||||
|
|
||||||
- **Razor component + code-behind** — `.razor` for markup, `.razor.cs` for logic.
|
|
||||||
- **Cascading parameters** for authentication state and layout context.
|
|
||||||
- **JS interop** via scoped modules (`ExampleJsInterop.cs` pattern).
|
|
||||||
|
|
||||||
## DLL References
|
|
||||||
|
|
||||||
- All AyCode.Core project references are via **DLL** (not ProjectReference) — this is intentional to separate build graphs.
|
|
||||||
- DevExpress references are NuGet packages pinned to **25.1.3**.
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
# Glossary
|
|
||||||
|
|
||||||
Blazor/MAUI UI terminology. For core framework terms see `AyCode.Core/docs/GLOSSARY.md`.
|
|
||||||
|
|
||||||
## UI Components
|
|
||||||
|
|
||||||
| Term | Definition |
|
|
||||||
|---|---|
|
|
||||||
| **DxGrid** | DevExpress Blazor data grid. Used with `AcSignalRDataSource` for real-time SignalR data. |
|
|
||||||
| **CardView** | Card-style layout wrapping DxGrid. Mobile-friendly alternative to table grids. |
|
|
||||||
| **AcSignalRDataSource** | Grid data source backed by SignalR. Handles load, CRUD, filtering, and change tracking. See `AyCode.Services.Server/docs/SIGNALR_DATASOURCE/README.md` in AyCode.Core repo. |
|
|
||||||
| **AcExpressionNode** | Serializable LINQ expression tree. Grid filters are serialized as expression nodes and sent to server. |
|
|
||||||
|
|
||||||
## MgGrid System
|
|
||||||
|
|
||||||
For full technical reference see `AyCode.Blazor.Components/docs/MGGRID/README.md`.
|
|
||||||
|
|
||||||
| Term | Definition |
|
|
||||||
|---|---|
|
|
||||||
| **MgGridBase** | Abstract generic grid component extending `DxGrid` with SignalR CRUD, layout persistence, master-detail, InfoPanel, fullscreen. |
|
|
||||||
| **MgGridWithInfoPanel** | `DxSplitter` wrapper: grid (left pane) + InfoPanel (right pane), fullscreen overlay, splitter size persistence. |
|
|
||||||
| **MgGridToolbarBase** | `DxToolbar` base with `Grid` (`IMgGridBase`) reference and `RefreshClick` callback. |
|
|
||||||
| **MgGridToolbarTemplate** | Full toolbar: New/Edit/Delete/Save/Cancel, row navigation, layout menu, export, fullscreen. Extensible via `ToolbarItemsExtended`. |
|
|
||||||
| **MgGridDataColumn** | Extended `DxGridDataColumn` with InfoPanel parameters and `UrlLink` template (`{Property}` placeholders). |
|
|
||||||
| **MgGridInfoPanel** | Default InfoPanel: column-value pairs for focused row, responsive columns, edit mode with typed editors, template system. |
|
|
||||||
| **MgGridSignalRDataSource** | `GridCustomDataSource` wrapping `AcSignalRDataSource`. Local cache, background refresh. |
|
|
||||||
| **IMgGridBase** | Public interface: `IsSyncing`, `GridEditState`, `ParentGrid`, `StepPrevRow/NextRow`, layout persistence methods. |
|
|
||||||
| **MgGridEditState** | Enum: `None` (no edit), `New` (adding item), `Edit` (modifying item). |
|
|
||||||
| **SignalRCrudTags** | Bundle of 5 integer message tags (GetAll, GetItem, Add, Update, Remove) for one entity type. See `AyCode.Services.Server/docs/SIGNALR_DATASOURCE/README.md` in AyCode.Core repo. |
|
|
||||||
| **IsMasterGrid** | `true` when `ParentDataItem == null` — top-level grid (not detail). |
|
|
||||||
| **AutoSaveLayoutName** | Base name for localStorage layout keys. Default: `"Grid{TDataItem.Name}"`. |
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
| Term | Definition |
|
|
||||||
|---|---|
|
|
||||||
| **Blazor Hybrid** | MAUI app hosting Blazor components via WebView. Shares UI code between web and native. |
|
|
||||||
| **DLL Reference** | AyCode.Core projects are referenced as DLLs (not ProjectReference). This is intentional — separates solution build graphs. |
|
|
||||||
| **AcBinaryHubProtocol** | Custom SignalR hub protocol using AcBinary serializer instead of default JSON. Used for performance. |
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
| Term | Definition |
|
|
||||||
|---|---|
|
|
||||||
| **AcBlazorLoginServiceBase** | Client-side login service managing JWT tokens, SecureStorage, and auto-refresh. |
|
|
||||||
| **AcAuthenticationStateProvider** | Custom Blazor auth state provider backed by JWT claims. |
|
|
||||||
|
|
||||||
## MAUI Platforms
|
|
||||||
|
|
||||||
| Term | Definition |
|
|
||||||
|---|---|
|
|
||||||
| **Platforms/** | Per-platform code folders: Android, iOS, Windows. MAUI SDK auto-includes based on target. |
|
|
||||||
| **API 33+** | Minimum Android API level (Android 13). |
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
# AyCode.Blazor documentation
|
|
||||||
|
|
||||||
Top-level documentation for the `AyCode.Blazor` repo (Blazor/MAUI UI framework — Layer 1).
|
|
||||||
|
|
||||||
## Reference docs (flat)
|
|
||||||
|
|
||||||
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — Repo architecture overview
|
|
||||||
- [`CONVENTIONS.md`](CONVENTIONS.md) — Coding conventions
|
|
||||||
- [`GLOSSARY.md`](GLOSSARY.md) — Domain glossary
|
|
||||||
|
|
||||||
## Topic folders
|
|
||||||
|
|
||||||
Topic-specific documentation lives at the sub-project level:
|
|
||||||
|
|
||||||
- **MGGRID** (data grid component family): `../AyCode.Blazor.Components/docs/MGGRID/README.md`
|
|
||||||
|
|
||||||
## Navigation
|
|
||||||
|
|
||||||
Per the AI Agent Core Protocol (folder navigation rule), start from this README when browsing `docs/`. Single-file reference docs remain flat at this level; multi-file topics live in named subfolders.
|
|
||||||
|
|
||||||
## See also
|
|
||||||
|
|
||||||
- **Base framework docs**: `../../AyCode.Core/docs/README.md` (if present) and per-project `docs/` folders under AyCode.Core.
|
|
||||||
Loading…
Reference in New Issue