InfoPanel: custom edit templates & null edit model fix
- Add InfoPanelEditTemplate to MgGridDataColumn for custom InfoPanel editors with refresh support - Introduce MgGridInfoPanelEditContext for edit context/refresh - Harden OnCustomizeEditModel to handle null edit models, preventing InfoPanel crashes and logging warnings - Update docs: usage, editor resolution order, and new issue entry (ACBLAZOR-GRID-I-N7K2) - Refactor grid templates to use new features and ensure consistent editor behavior - Minor doc and formatting improvements
This commit is contained in:
parent
076162f6ee
commit
a254dbe722
|
|
@ -509,11 +509,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
||||||
protected PropertyInfo? GetDataItemPropertyInfo(string propertyName)
|
protected PropertyInfo? GetDataItemPropertyInfo(string propertyName)
|
||||||
=> typeof(TDataItem).GetProperty(propertyName);
|
=> typeof(TDataItem).GetProperty(propertyName);
|
||||||
|
|
||||||
protected virtual async Task OnCustomizeEditModel(GridCustomizeEditModelEventArgs e)
|
private void InitializeNewEditModel(TDataItem editModel)
|
||||||
{
|
|
||||||
var editModel = (e.EditModel as TDataItem)!;
|
|
||||||
|
|
||||||
if (e.IsNew)
|
|
||||||
{
|
{
|
||||||
if (!HasIdValue(editModel)) SetNewId(editModel);
|
if (!HasIdValue(editModel)) SetNewId(editModel);
|
||||||
|
|
||||||
|
|
@ -522,7 +518,15 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
||||||
KeyFieldPropertyInfoToParent ??= GetDataItemPropertyInfo(KeyFieldNameToParentId);
|
KeyFieldPropertyInfoToParent ??= GetDataItemPropertyInfo(KeyFieldNameToParentId);
|
||||||
KeyFieldPropertyInfoToParent!.SetValue(editModel, ParentDataItem.Id);
|
KeyFieldPropertyInfoToParent!.SetValue(editModel, ParentDataItem.Id);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual async Task OnCustomizeEditModel(GridCustomizeEditModelEventArgs e)
|
||||||
|
{
|
||||||
|
var editModel = e.EditModel as TDataItem;
|
||||||
|
|
||||||
|
if (e.IsNew && editModel != null)
|
||||||
|
{
|
||||||
|
InitializeNewEditModel(editModel);
|
||||||
e.EditModel = editModel;
|
e.EditModel = editModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -531,8 +535,24 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
||||||
|
|
||||||
await OnGridCustomizeEditModel.InvokeAsync(e);
|
await OnGridCustomizeEditModel.InvokeAsync(e);
|
||||||
|
|
||||||
|
// Re-read after the consumer callback — it may have created/replaced the edit model
|
||||||
|
// (the official DevExpress pattern for types without a parameterless constructor)
|
||||||
|
var preCallbackModel = editModel;
|
||||||
|
editModel = e.EditModel as TDataItem;
|
||||||
|
|
||||||
|
// Initialize any IsNew instance the pre-callback block has not seen (consumer-replaced or consumer-created)
|
||||||
|
if (e.IsNew && editModel != null && !ReferenceEquals(editModel, preCallbackModel))
|
||||||
|
InitializeNewEditModel(editModel);
|
||||||
|
|
||||||
// Update InfoPanel to edit mode
|
// Update InfoPanel to edit mode
|
||||||
|
if (editModel != null)
|
||||||
|
{
|
||||||
InfoPanelInstance?.SetEditMode(this, editModel);
|
InfoPanelInstance?.SetEditMode(this, editModel);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Logger.Warning($"[{GetType().Name}] CustomizeEditModel produced no edit model (EditModel is null or not {typeof(TDataItem).Name}); InfoPanel stays in view mode");
|
||||||
|
}
|
||||||
|
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,12 @@ using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace AyCode.Blazor.Components.Components.Grids;
|
namespace AyCode.Blazor.Components.Components.Grids;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Context for <see cref="MgGridDataColumn.InfoPanelEditTemplate"/>: the edit model plus a Refresh action
|
||||||
|
/// that re-renders the InfoPanel — call it after mutations other fields depend on (e.g. cascading lookups).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record MgGridInfoPanelEditContext(object EditModel, Action Refresh);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extended DxGridDataColumn with additional parameters for InfoPanel support.
|
/// Extended DxGridDataColumn with additional parameters for InfoPanel support.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -36,6 +42,14 @@ public partial class MgGridDataColumn : DxGridDataColumn
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public int InfoPanelOrder { get; set; } = int.MaxValue;
|
public int InfoPanelOrder { get; set; } = int.MaxValue;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom InfoPanel editor for this column (edit mode). Takes precedence over the EditSettings /
|
||||||
|
/// property-type based default editors. Use when the grid-side editor is a CellEditTemplate the
|
||||||
|
/// InfoPanel cannot reuse (e.g. cascading lookups).
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public RenderFragment<MgGridInfoPanelEditContext>? InfoPanelEditTemplate { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// URL template with {property} placeholders that will be replaced with row values.
|
/// URL template with {property} placeholders that will be replaced with row values.
|
||||||
/// Example: https://shop.fruitbank.hu/Admin/Order/Edit/{Id}/{OrderId}
|
/// Example: https://shop.fruitbank.hu/Admin/Order/Edit/{Id}/{OrderId}
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,14 @@
|
||||||
return builder =>
|
return builder =>
|
||||||
{
|
{
|
||||||
var seq = 0;
|
var seq = 0;
|
||||||
|
|
||||||
|
// Column-provided editor template wins (e.g. cascading lookups the settings pipeline cannot express)
|
||||||
|
if (column is MgGridDataColumn { InfoPanelEditTemplate: not null } mgColumn)
|
||||||
|
{
|
||||||
|
builder.AddContent(seq++, mgColumn.InfoPanelEditTemplate(new MgGridInfoPanelEditContext(dataItem, () => InvokeAsync(StateHasChanged))));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var propertyInfo = dataItemType.GetProperty(column.FieldName);
|
var propertyInfo = dataItemType.GetProperty(column.FieldName);
|
||||||
|
|
||||||
if (propertyInfo == null)
|
if (propertyInfo == null)
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,26 @@ Extended `DxGridDataColumn` with InfoPanel and URL link support.
|
||||||
| `ShowInInfoPanel` | `bool` | `true` | Whether this column is visible in InfoPanel |
|
| `ShowInInfoPanel` | `bool` | `true` | Whether this column is visible in InfoPanel |
|
||||||
| `InfoPanelDisplayFormat` | `string?` | `null` | Custom display format for InfoPanel |
|
| `InfoPanelDisplayFormat` | `string?` | `null` | Custom display format for InfoPanel |
|
||||||
| `InfoPanelOrder` | `int` | `int.MaxValue` | Column order in InfoPanel (lower = earlier) |
|
| `InfoPanelOrder` | `int` | `int.MaxValue` | Column order in InfoPanel (lower = earlier) |
|
||||||
|
| `InfoPanelEditTemplate` | `RenderFragment<MgGridInfoPanelEditContext>?` | `null` | Custom InfoPanel editor (edit mode) — takes precedence over the `EditSettings` / property-type default editors |
|
||||||
| `UrlLink` | `string?` | `null` | URL template with `{Property}` placeholders |
|
| `UrlLink` | `string?` | `null` | URL template with `{Property}` placeholders |
|
||||||
|
|
||||||
**UrlLink example:** `https://admin.example.com/Entity/Edit/{Id}` — renders cell as `<a href="..." target="_blank">`.
|
**UrlLink example:** `https://admin.example.com/Entity/Edit/{Id}` — renders cell as `<a href="..." target="_blank">`.
|
||||||
|
|
||||||
|
## InfoPanelEditTemplate
|
||||||
|
|
||||||
|
Use when the grid-side editor is a `CellEditTemplate` the InfoPanel cannot reuse (it is bound to the grid-cell
|
||||||
|
context) — without it, the panel falls back to the property-type default editor (e.g. `int` → number spinner).
|
||||||
|
Context: `MgGridInfoPanelEditContext(object EditModel, Action Refresh)` — call `Refresh()` after mutations
|
||||||
|
other fields depend on (cascading lookups), it re-renders the InfoPanel.
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<MgGridDataColumn FieldName="CategoryId" Caption="Category">
|
||||||
|
<CellEditTemplate>@CategoryEditor((MyItem)context.EditModel)</CellEditTemplate>
|
||||||
|
<InfoPanelEditTemplate>@CategoryEditor((MyItem)context.EditModel, context.Refresh)</InfoPanelEditTemplate>
|
||||||
|
</MgGridDataColumn>
|
||||||
|
```
|
||||||
|
|
||||||
|
Define the editor once as a shared fragment (`private RenderFragment CategoryEditor(MyItem item, Action? refresh = null) => @<DxComboBox ...>;`)
|
||||||
|
so the grid cell and the InfoPanel render the same component — no duplicated markup.
|
||||||
|
|
||||||
Uses compiled property accessors (`ConcurrentDictionary` cache) for performance.
|
Uses compiled property accessors (`ConcurrentDictionary` cache) for performance.
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,10 @@ Edit starts → InfoPanelInstance.SetEditMode(grid, editModel)
|
||||||
Edit ends/cancel → InfoPanelInstance.ClearEditMode()
|
Edit ends/cancel → InfoPanelInstance.ClearEditMode()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When the grid produces no edit model (`CustomizeEditModel` fires with a null `EditModel` — e.g. stale
|
||||||
|
focused row after a datasource reload), `SetEditMode` is skipped with a `Logger.Warning` and the panel
|
||||||
|
stays in view mode (see `MGGRID_ISSUES.md#acblazor-grid-i-n7k2`).
|
||||||
|
|
||||||
`InfoPanelInstance` resolution: own `GridWrapper.InfoPanelInstance` → root grid's `GridWrapper.InfoPanelInstance` → `null`.
|
`InfoPanelInstance` resolution: own `GridWrapper.InfoPanelInstance` → root grid's `GridWrapper.InfoPanelInstance` → `null`.
|
||||||
|
|
||||||
### Responsive Column Layout
|
### Responsive Column Layout
|
||||||
|
|
@ -77,10 +81,20 @@ Edit ends/cancel → InfoPanelInstance.ClearEditMode()
|
||||||
|
|
||||||
`InfoPanelContext` = `record(object? DataItem, bool IsEditMode)`.
|
`InfoPanelContext` = `record(object? DataItem, bool IsEditMode)`.
|
||||||
|
|
||||||
### Edit Mode Editors (by property type)
|
### Edit Mode Editors
|
||||||
|
|
||||||
|
Edit mode reads/writes properties via reflection and **assumes non-throwing accessors** — computed
|
||||||
|
properties (e.g. GenericAttribute-backed getters that fail on edit-model copies, or deliberately
|
||||||
|
throwing setters) MUST be declared `ReadOnly` on the column, otherwise rendering the editor crashes.
|
||||||
|
This is by design (fail-fast): the missing `ReadOnly` declaration is a consumer bug that should surface
|
||||||
|
in development, not be masked by silent fallbacks.
|
||||||
|
|
||||||
|
Editor resolution order: **column `InfoPanelEditTemplate` first** (`MgGridDataColumn` — see `MGGRID_COLUMNS.md`),
|
||||||
|
then `EditSettings` (`DxComboBoxSettings`, `Memo`), then property type:
|
||||||
|
|
||||||
| Type | Editor Component |
|
| Type | Editor Component |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| Column `InfoPanelEditTemplate` (`MgGridDataColumn`) | the provided template — wins over everything below |
|
||||||
| `bool` | `DxCheckBox<bool>` |
|
| `bool` | `DxCheckBox<bool>` |
|
||||||
| `DateTime` / `DateTime?` | `DxDateEdit<DateTime>` / `DxDateEdit<DateTime?>` |
|
| `DateTime` / `DateTime?` | `DxDateEdit<DateTime>` / `DxDateEdit<DateTime?>` |
|
||||||
| `DateOnly` / `DateOnly?` | `DxDateEdit<DateOnly>` / `DxDateEdit<DateOnly?>` |
|
| `DateOnly` / `DateOnly?` | `DxDateEdit<DateOnly>` / `DxDateEdit<DateOnly?>` |
|
||||||
|
|
@ -94,5 +108,5 @@ Edit ends/cancel → InfoPanelInstance.ClearEditMode()
|
||||||
### Additional Features
|
### Additional Features
|
||||||
|
|
||||||
- **Sticky positioning** via JS interop (`MgGridInfoPanel.initSticky`)
|
- **Sticky positioning** via JS interop (`MgGridInfoPanel.initSticky`)
|
||||||
- **Built-in toolbar** with `MgGridToolbarTemplate` (`OnlyGridEditTools=true`)
|
- **Built-in toolbar** with `MgGridToolbarTemplate` (`OnlyGridEditTools=true`) — its edit tools are currently not configurable per grid (hardcoded `EnableNew`/`EnableEdit` defaults; see `MGGRID_ISSUES.md#acblazor-grid-i-k5r3`)
|
||||||
- **OnDataItemChanged** callback when focused row changes
|
- **OnDataItemChanged** callback when focused row changes
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,65 @@
|
||||||
# MGGRID — Known Issues
|
# MGGRID — Known Issues
|
||||||
|
|
||||||
For planned/actionable work see `MGGRID_TODO.md`.
|
For planned/actionable work see `MGGRID_TODO.md`. In-code TODOs are recorded as TODO entries (`ACBLAZOR-GRID-T-*`) there, since they describe unfinished work rather than confirmed broken behaviour.
|
||||||
|
|
||||||
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.
|
## ACBLAZOR-GRID-I-N7K2: InfoPanel.SetEditMode crashes the WASM renderer when DxGrid produces no edit model
|
||||||
|
|
||||||
Add the first `## ACBLAZOR-GRID-I-XXXX: ...` entry below when a concrete issue is observed.
|
**Severity:** Major (unhandled exception killed the renderer) · **Status:** Closed (2026-07-12) · **Area:** `Components/Grids/MgGridBase.cs` → `OnCustomizeEditModel`
|
||||||
|
|
||||||
|
### Description
|
||||||
|
Clicking the toolbar Edit button could crash the entire WebAssembly renderer with
|
||||||
|
`System.ArgumentNullException: editModel` thrown by `MgGridInfoPanel.SetEditMode`. Observed on a
|
||||||
|
`ProductDto` grid right after a datasource reload (`OnDataSourceLoaded` → immediate Edit click):
|
||||||
|
DevExpress `StartEditRowAsync` raised `CustomizeEditModel` with `e.EditModel == null` (stale
|
||||||
|
`FocusedRowVisibleIndex` → null data item), and `OnCustomizeEditModel` passed the null straight to
|
||||||
|
`SetEditMode`, whose `ArgumentNullException.ThrowIfNull` escalated to an unhandled rendering exception.
|
||||||
|
|
||||||
|
### Root cause
|
||||||
|
`MgGridBase.OnCustomizeEditModel` captured `(e.EditModel as TDataItem)!` (null-forgiving cast) with no
|
||||||
|
null check, and on the Edit path (`IsNew == false`) nothing validated it before
|
||||||
|
`InfoPanelInstance.SetEditMode`. The local was also captured BEFORE the `OnGridCustomizeEditModel`
|
||||||
|
consumer callback — so a consumer-created edit model (the official DevExpress pattern for types without
|
||||||
|
a parameterless constructor) never reached the InfoPanel either.
|
||||||
|
|
||||||
|
### Resolution (2026-07-12)
|
||||||
|
`OnCustomizeEditModel` hardened: null-tolerant cast, guarded `IsNew` branch, the edit model is re-read
|
||||||
|
AFTER the consumer callback, and when it is still null the InfoPanel stays in view mode with a
|
||||||
|
`Logger.Warning` (`[GridName] CustomizeEditModel produced no edit model...`) instead of crashing.
|
||||||
|
|
||||||
|
### Known workaround
|
||||||
|
None needed after the fix.
|
||||||
|
|
||||||
|
### Related TODO
|
||||||
|
None.
|
||||||
|
|
||||||
|
## ACBLAZOR-GRID-I-K5R3: InfoPanel built-in toolbar edit tools are not configurable
|
||||||
|
|
||||||
|
**Severity:** Minor (UX / consistency gap; can re-open a crash path on listing-only grids) · **Status:** Open · **Area:** `Components/Grids/MgGridInfoPanel.razor` (built-in toolbar) + `MgGridToolbarTemplate`
|
||||||
|
|
||||||
|
### Description
|
||||||
|
`MgGridInfoPanel` renders its built-in toolbar with hardcoded defaults
|
||||||
|
(`MgGridInfoPanel.razor:20`: `<MgGridToolbarTemplate Grid="_currentGrid" OnlyGridEditTools="true" ShowOnlyIcon="true" />`),
|
||||||
|
so `EnableNew` / `EnableEdit` are always `true` there. When a consumer disables New/Edit on its own
|
||||||
|
master toolbar (e.g. a listing-only grid such as a product grid whose data is managed in an external
|
||||||
|
admin and has no Add/Update SignalR tags), the InfoPanel toolbar still offers **+** and **edit** —
|
||||||
|
the edit flow can be started through a side door the consumer cannot close.
|
||||||
|
|
||||||
|
### Root cause
|
||||||
|
The panel's toolbar instance is not parameterizable from the outside, and there is no grid-level
|
||||||
|
edit-enable flag the toolbar could consult — the `Enable*` flags live only on individual
|
||||||
|
`MgGridToolbarTemplate` instances.
|
||||||
|
|
||||||
|
### Known workaround
|
||||||
|
None from the consumer side (the panel toolbar cannot be reached). Master-toolbar `EnableNew="false"
|
||||||
|
EnableEdit="false"` covers the primary path only.
|
||||||
|
|
||||||
|
### Proposed fix
|
||||||
|
Grid-level `EditItemsEnabled` parameter on `MgGridBase` / `IMgGridBase` (default `true`);
|
||||||
|
`MgGridToolbarTemplate` computes effective enablement as `own Enable* && Grid.EditItemsEnabled`,
|
||||||
|
so both the master and the built-in InfoPanel toolbar respect a single consumer declaration.
|
||||||
|
|
||||||
|
### Related TODO
|
||||||
|
None yet — becomes a TODO when scheduled.
|
||||||
|
|
||||||
## Issue entry template
|
## Issue entry template
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
| `MGGRID_TOOLBAR.md` | MgGridToolbarTemplate (buttons, parameters, state) |
|
| `MGGRID_TOOLBAR.md` | MgGridToolbarTemplate (buttons, parameters, state) |
|
||||||
| `MGGRID_COLUMNS.md` | MgGridDataColumn (InfoPanel params, UrlLink) |
|
| `MGGRID_COLUMNS.md` | MgGridDataColumn (InfoPanel params, UrlLink) |
|
||||||
| `MGGRID_DATASOURCE.md` | MgGridSignalRDataSource (server-side data, local cache, background refresh) |
|
| `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_ISSUES.md` | Known issues (`ACBLAZOR-GRID-I-*`, `ACBLAZOR-GRID-B-*`) |
|
||||||
| `MGGRID_TODO.md` | Planned work (`ACBLAZOR-GRID-T-*`) — refactors, missing features, optimizations |
|
| `MGGRID_TODO.md` | Planned work (`ACBLAZOR-GRID-T-*`) — refactors, missing features, optimizations |
|
||||||
|
|
||||||
## Component Hierarchy
|
## Component Hierarchy
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue