Compare commits
76 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
92296c479b | |
|
|
b61b8085df | |
|
|
3cee1e20d7 | |
|
|
b4d68e1fa4 | |
|
|
1a7bb3bc10 | |
|
|
806c6e5351 | |
|
|
5d4f078906 | |
|
|
161ef4bba9 | |
|
|
b39d624406 | |
|
|
f1e93f939f | |
|
|
bcea6b3205 | |
|
|
d5fdd57ddd | |
|
|
045b8e1526 | |
|
|
da11df5384 | |
|
|
b062c4c712 | |
|
|
d1567323d8 | |
|
|
453e21a844 | |
|
|
52131fdf25 | |
|
|
6cce23a124 | |
|
|
b80b117a38 | |
|
|
79eceff47a | |
|
|
85dbb853c7 | |
|
|
56475da43b | |
|
|
cc2ab55402 | |
|
|
518cfa6865 | |
|
|
873ffe91d2 | |
|
|
40223f9182 | |
|
|
0da7b67c60 | |
|
|
ac244fb9fb | |
|
|
6a95f1cc00 | |
|
|
d5a908a46f | |
|
|
9fc12e2fcc | |
|
|
46cafcf382 | |
|
|
7041b795ad | |
|
|
e4e279e54c | |
|
|
b6f51bc2a1 | |
|
|
500e39a514 | |
|
|
f95602e82d | |
|
|
c468583afd | |
|
|
4dbeb507fe | |
|
|
bad4e50c17 | |
|
|
9d682bd741 | |
|
|
057f576375 | |
|
|
15776ca537 | |
|
|
017eb16c4b | |
|
|
4c86914884 | |
|
|
5255917210 | |
|
|
739d0fa808 | |
|
|
112d633590 | |
|
|
fe1a59a0bd | |
|
|
90f12a160e | |
|
|
109a4b82b4 | |
|
|
45294199cf | |
|
|
c1cf30b8f0 | |
|
|
920bc299aa | |
|
|
06f397e285 | |
|
|
0e2d31aa4b | |
|
|
687b745b4d | |
|
|
06c266cc78 | |
|
|
baaceacdd3 | |
|
|
44103b9613 | |
|
|
dc4321c1d6 | |
|
|
8d4dd5aa90 | |
|
|
650066becf | |
|
|
935b6abf9a | |
|
|
5e74b93135 | |
|
|
b7c2e7ea16 | |
|
|
9d0ee493ac | |
|
|
7a76138d9a | |
|
|
85032a98fc | |
|
|
7db379cd81 | |
|
|
35c4d938e3 | |
|
|
942bac61fd | |
|
|
dca43794c8 | |
|
|
5f661991f9 | |
|
|
6fb3d0d848 |
|
|
@ -0,0 +1,43 @@
|
|||
# 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
|
|
@ -0,0 +1,24 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="bunit" Version="2.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>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
using Bunit;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AyCode.Blazor.Components.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for bUnit tests using MSTest.
|
||||
/// Provides BunitContext setup and teardown.
|
||||
/// </summary>
|
||||
public abstract class BunitTestContext : TestContextWrapper
|
||||
{
|
||||
[TestInitialize]
|
||||
public void Setup() => Context = new BunitContext();
|
||||
|
||||
[TestCleanup]
|
||||
public void TearDown() => Context?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for bUnit BunitContext to work with MSTest lifecycle.
|
||||
/// </summary>
|
||||
public abstract class TestContextWrapper
|
||||
{
|
||||
protected BunitContext? Context { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
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>
|
||||
{
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# 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 |
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
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,53 +1,68 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<RunAOTCompilation>true</RunAOTCompilation>
|
||||
<WasmStripILAfterAOT>true</WasmStripILAfterAOT>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="..//AyCode.Blazor.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<SupportedPlatform Include="browser" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<SupportedPlatform Include="browser" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DevExpress.Blazor" Version="24.1.3" />
|
||||
<PackageReference Include="DevExpress.Blazor.Dashboard" Version="24.1.3" />
|
||||
<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>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DevExpress.Blazor" Version="25.1.3" />
|
||||
<PackageReference Include="DevExpress.Data" Version="25.1.3" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
|
||||
<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>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Core" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.11" />
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Layouts\" />
|
||||
<Folder Include="Pages\" />
|
||||
</ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AyCode.Blazor.Models\AyCode.Blazor.Models.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="AyCode.Core">
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.dll</HintPath>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
@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>
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,982 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[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, 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();
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace AyCode.Blazor.Components.Components.Grids
|
||||
{
|
||||
internal class MgGridHelper
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,401 @@
|
|||
@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();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,488 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
/* 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 */
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
namespace AyCode.Blazor.Components.Components.Grids;
|
||||
|
||||
public class MgGridInfoPanelHelper
|
||||
{
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,462 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
namespace AyCode.Blazor.Components.Components.Grids;
|
||||
|
||||
public class MgGridToolbarHelper
|
||||
{
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
@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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
@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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# 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).
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
namespace AyCode.Blazor.Components.Components;
|
||||
|
||||
public class MgComponentsHelper
|
||||
{
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
@using Microsoft.JSInterop
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div @ref="_containerRef" class="@ContainerCssClass" style="@ContainerStyle">
|
||||
@if (IsVisible || ForceRender)
|
||||
{
|
||||
@ChildContent
|
||||
}
|
||||
else if (PlaceholderContent != null)
|
||||
{
|
||||
@PlaceholderContent
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="lazy-content-placeholder" style="min-height: @MinHeight;">
|
||||
@if (ShowLoadingIndicator)
|
||||
{
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Betöltés...</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private ElementReference _containerRef;
|
||||
private DotNetObjectReference<MgLazyLoadContent>? _dotNetRef;
|
||||
private bool _isObserverInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// Content to render when visible
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional placeholder content to show before the element becomes visible
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? PlaceholderContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Root margin for IntersectionObserver (e.g., "100px" to load 100px before visible)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string RootMargin { get; set; } = "50px";
|
||||
|
||||
/// <summary>
|
||||
/// Threshold for IntersectionObserver (0.0 to 1.0)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public double Threshold { get; set; } = 0.01;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum height for the placeholder (prevents layout shift)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string MinHeight { get; set; } = "100px";
|
||||
|
||||
/// <summary>
|
||||
/// CSS class for the container
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? ContainerCssClass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Inline style for the container
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? ContainerStyle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Force render regardless of visibility (useful for disabling lazy loading)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool ForceRender { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Show a loading spinner in the placeholder
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool ShowLoadingIndicator { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Callback when content becomes visible
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback OnContentVisible { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the content is currently visible
|
||||
/// </summary>
|
||||
public bool IsVisible { get; private set; }
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && !ForceRender)
|
||||
{
|
||||
await InitializeObserverAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeObserverAsync()
|
||||
{
|
||||
if (_isObserverInitialized) return;
|
||||
|
||||
try
|
||||
{
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
|
||||
// Initialize observer and check immediate visibility
|
||||
var isCurrentlyVisible = await JS.InvokeAsync<bool>(
|
||||
"lazyContentObserver.observe",
|
||||
_containerRef,
|
||||
_dotNetRef,
|
||||
RootMargin,
|
||||
Threshold);
|
||||
|
||||
_isObserverInitialized = true;
|
||||
|
||||
// If already visible, trigger the callback immediately
|
||||
if (isCurrentlyVisible && !IsVisible)
|
||||
{
|
||||
await OnVisibilityChanged(true);
|
||||
}
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
Console.WriteLine($"MgLazyLoadContent: Failed to initialize observer: {ex.Message}");
|
||||
// Fallback: render immediately if JS fails
|
||||
IsVisible = true;
|
||||
await OnContentVisible.InvokeAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnVisibilityChanged(bool isVisible)
|
||||
{
|
||||
if (IsVisible == isVisible) return;
|
||||
|
||||
IsVisible = isVisible;
|
||||
|
||||
if (IsVisible)
|
||||
{
|
||||
await OnContentVisible.InvokeAsync();
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually triggers the OnContentVisible callback if the content is currently visible.
|
||||
/// Useful when the content data changes but visibility hasn't changed.
|
||||
/// </summary>
|
||||
public async Task TriggerContentVisibleAsync()
|
||||
{
|
||||
if (IsVisible)
|
||||
{
|
||||
await OnContentVisible.InvokeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_isObserverInitialized)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("lazyContentObserver.unobserve", _containerRef);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors during disposal
|
||||
}
|
||||
}
|
||||
|
||||
_dotNetRef?.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# 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,7 +6,6 @@ using System.Collections.Generic;
|
|||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AyCode.Core.Interfaces;
|
||||
using DevExpress.Data.Filtering.Helpers;
|
||||
using DevExpress.Data.Linq;
|
||||
using DevExpress.Data.Linq.Helpers;
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
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()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
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
|
|
@ -0,0 +1,356 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
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; }
|
||||
}
|
||||
|
|
@ -0,0 +1,429 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# 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.
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
namespace AyCode.Blazor.Components.Services;
|
||||
|
||||
public interface IAcSessionItem<TSessionItemId> where TSessionItemId : notnull
|
||||
{
|
||||
public TSessionItemId SessionId { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace AyCode.Blazor.Components.Services.Logins;
|
||||
|
||||
public class AcWebAuthService
|
||||
{
|
||||
public virtual void Logout()
|
||||
{}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# 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.
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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!;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# 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
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
# 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
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# 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).
|
||||
```
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
# 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`).
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# 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
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
# 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.
|
||||
```
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# 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` |
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
# 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` |
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# 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`
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
/* 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';
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
// This is a JavaScript module that is loaded on demand. It can export any number of
|
||||
// functions, and may import other JavaScript modules if required.
|
||||
|
||||
export function showPrompt(message) {
|
||||
return prompt(message, 'Type anything here');
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
// 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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
// 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;
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
// Lazy content observer - renders content only when visible in viewport
|
||||
window.lazyContentObserver = {
|
||||
_observers: new Map(),
|
||||
|
||||
observe: function (element, dotNetRef, rootMargin, threshold) {
|
||||
if (!element) {
|
||||
console.error('[MgLazyLoadContent] Element not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clean up existing observer for this element
|
||||
this.unobserve(element);
|
||||
|
||||
const options = {
|
||||
root: null, // viewport
|
||||
rootMargin: rootMargin || '50px',
|
||||
threshold: threshold || 0.01
|
||||
};
|
||||
|
||||
// Check immediate visibility before setting up observer
|
||||
const rect = element.getBoundingClientRect();
|
||||
const isCurrentlyVisible = (
|
||||
rect.top < window.innerHeight &&
|
||||
rect.bottom > 0 &&
|
||||
rect.left < window.innerWidth &&
|
||||
rect.right > 0
|
||||
);
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
// Invoke .NET callback
|
||||
dotNetRef.invokeMethodAsync('OnVisibilityChanged', entry.isIntersecting)
|
||||
.catch(err => console.error('[MgLazyLoadContent] Callback error:', err));
|
||||
});
|
||||
}, options);
|
||||
|
||||
observer.observe(element);
|
||||
this._observers.set(element, { observer, dotNetRef });
|
||||
|
||||
console.log('[MgLazyLoadContent] Observer initialized. Currently visible:', isCurrentlyVisible);
|
||||
|
||||
// Return immediate visibility status
|
||||
return isCurrentlyVisible;
|
||||
},
|
||||
|
||||
unobserve: function (element) {
|
||||
if (!element) return;
|
||||
|
||||
const data = this._observers.get(element);
|
||||
if (data) {
|
||||
data.observer.disconnect();
|
||||
this._observers.delete(element);
|
||||
console.log('[MgLazyLoadContent] Observer removed');
|
||||
}
|
||||
},
|
||||
|
||||
dispose: function () {
|
||||
this._observers.forEach((data, element) => {
|
||||
data.observer.disconnect();
|
||||
});
|
||||
this._observers.clear();
|
||||
console.log('[MgLazyLoadContent] All observers disposed');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,64 +1,64 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<RunAOTCompilation>true</RunAOTCompilation>
|
||||
<WasmStripILAfterAOT>true</WasmStripILAfterAOT>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AyCode.Blazor.Models.Server\AyCode.Blazor.Models.Server.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Blazor.Models\AyCode.Blazor.Models.csproj" />
|
||||
</ItemGroup>
|
||||
<Import Project="..//AyCode.Blazor.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="AyCode.Core">
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.dll</HintPath>
|
||||
</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>
|
||||
<ProjectReference Include="..\AyCode.Blazor.Models.Server\AyCode.Blazor.Models.Server.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Blazor.Models\AyCode.Blazor.Models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Loggers\" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="AyCode.Core">
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.dll</HintPath>
|
||||
</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>
|
||||
<PackageReference Include="MessagePack" Version="2.5.187" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.10" />
|
||||
<PackageReference Include="Serialize.Linq" Version="3.1.160" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Loggers\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
# 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,42 +1,51 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<RunAOTCompilation>true</RunAOTCompilation>
|
||||
<WasmStripILAfterAOT>true</WasmStripILAfterAOT>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="AyCode.Core">
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.dll</HintPath>
|
||||
</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.Server">
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.Server.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>
|
||||
<Import Project="..//AyCode.Blazor.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="AyCode.Core">
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.dll</HintPath>
|
||||
</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.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>
|
||||
<Folder Include="Models\" />
|
||||
<Folder Include="Services\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
# 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,45 +1,46 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<RunAOTCompilation>true</RunAOTCompilation>
|
||||
<WasmStripILAfterAOT>true</WasmStripILAfterAOT>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="AyCode.Core">
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.dll</HintPath>
|
||||
</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.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>
|
||||
<Import Project="..//AyCode.Blazor.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="AyCode.Core">
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="AyCode.Core.Server">
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.Server.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>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
# 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 |
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# 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
|
||||
```
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<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,47 +1,56 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<Import Project="..//AyCode.Maui.targets" />
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net8.0;net8.0-maccatalyst;net8.0-ios;net8.0-android34.0</TargetFrameworks>
|
||||
<TargetFrameworks>net10.0-android;net10.0-ios</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 -->
|
||||
<!-- <TargetFrameworks>$(TargetFrameworks);net8.0-tizen</TargetFrameworks> -->
|
||||
<!-- <TargetFrameworks>$(TargetFrameworks);net9.0-tizen</TargetFrameworks> -->
|
||||
<UseMaui>true</UseMaui>
|
||||
<SingleProject>true</SingleProject>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">14.2</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">14.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">29.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
|
||||
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion>
|
||||
<RunAOTCompilation>true</RunAOTCompilation>
|
||||
<WasmStripILAfterAOT>true</WasmStripILAfterAOT>
|
||||
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">33.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.19041.0</SupportedOSPlatformVersion>
|
||||
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.19041.0</TargetPlatformMinVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MessagePack" Version="2.5.187" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.10" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="AyCode.Core">
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.dll</HintPath>
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="AyCode.Entities">
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.dll</HintPath>
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Entities.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="AyCode.Interfaces">
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.dll</HintPath>
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Interfaces.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="AyCode.Models">
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.dll</HintPath>
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Models.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="AyCode.Services">
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Services.dll</HintPath>
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="AyCode.Utils">
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Utils.dll</HintPath>
|
||||
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Utils.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# 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 |
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
|
||||
<Import Project="AyCode.Project.targets" />
|
||||
<PropertyGroup>
|
||||
<Name>AyCode.Maui.targets</Name>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<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,6 +13,23 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AyCode.Blazor.Models", "AyC
|
|||
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}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
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.
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# 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 |
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
# 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
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# 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**.
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# 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). |
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# 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