Compare commits

...

6 Commits

Author SHA1 Message Date
Loretta 11d1ae1d17 Improve filter panel restore logic and doc clarity
Major overhaul of MgGridFilterPanelTagBox restore/healing logic:
- Level-triggered healing prevents lost filter criteria after async loads.
- Healing now only triggers when needed; delayed assert ensures stability.
- ValuesChanged event guarded to avoid accidental filter clearing.
- Diagnostics dormant but retained for future debugging.
- Comments and summaries updated for clarity.

Docs (MGGRID_PARAMETERS.md, 0004-signalr-datasource-server-side-filtering.md) updated to explain new logic, rationale, and future plans.

Minor refactor in GridShippingDocument.razor for reload clarity.
No functional change in ShippingsAdmin.razor (comment only).
2026-07-17 15:23:24 +02:00
Loretta efd4c67cc0 Fix filter panel restore race; doc v2 plan & logging
Refactored MgGridFilterPanelTagBox to robustly heal filter criteria vs. async lookup data race, using level-triggered snapshot detection and reload. Added diagnostic logging. Updated MGGRID docs to explain the new logic, rationale, and planned v2 layout envelope solution. Minor unrelated shell command tweaks in settings.local.json.
2026-07-17 06:31:41 +02:00
Loretta d164a99ed9 Fix: TagBox reloads on in-place Data refill
Improved MgGridFilterPanelTagBox<TData, TValue> to detect in-place Data collection refills and trigger DevExpress editor Reload(), ensuring tags resolve when the collection is updated without changing its reference. Updated MGGRID_PARAMETERS.md to document this behavior and clarify why Reload() is needed for in-place fills.
2026-07-17 05:51:31 +02:00
Loretta b06d321bfb Refactor grid filter panel system for clarity/extensibility
Refactored the grid filter panel system:
- Renamed "quick-filter panel" to "filter panel" throughout.
- Introduced IMgGridFilterPanelComponent interface for filter panel contract.
- Split MgTagBox (now behavior-free) and new MgGridFilterPanelTagBox for grid filter logic.
- Grid tracks filter panel components, synchronizes criteria, and exposes new filter panel methods.
- Filter panel always rendered (CSS-hidden when off) to prevent invisible filtering.
- Added right-aligned "Clear Filters" button for panel-owned filters.
- Updated docs, parameter tables, and glossary for new terminology and structure.
- Updated usages to new component; legacy grid class implements no-op interface.
- Documented edge cases and updated CSS for new layout.
2026-07-17 05:34:01 +02:00
Loretta 1e518d9a1e Refactor MgGridBase filter panel & unify filter criteria
- Moved quick-filter panel below toolbar in MgGridBase for improved layout and visual alignment; FilterPanel now renders as a styled band between toolbar and group panel.
- Exposed GridSizeMode on IMgGridBase and MgGridBase; MgTagBox now inherits grid density by default for consistent sizing.
- Renamed FilterText to CustomFilterCriteria across grid/data source classes and tests to clarify its role as a DevExpress criteria string.
- Updated documentation (README.md, MGGRID_PARAMETERS.md, GLOSSARY.md, ADR 0004) to reflect filter panel placement, parameter naming, and sizing/layout changes.
- Adjusted CSS for new filter panel layout and visual integration.
- Updated example usages and tests to match new API and layout.
2026-07-16 15:01:37 +02:00
Loretta 2d62fad300 Add quick-filter panel slot to MgGridBase
Introduced a flexible quick-filter panel to the grid system via a new FilterPanel slot in MgGridBase. Added interface and implementation for filter panel visibility and toggle logic. Updated toolbar to show a Show/Hide Filters button when applicable. MgTagBox now applies per-field quick-filters using SetFieldQuickFilter. Removed redundant MgGridFilterPanelBase. Updated documentation and ADR 0004 to cover the new feature. Added CSS for consistent filter panel styling and demonstrated usage in GridPartner.razor. Legacy MgGridBase updated for compatibility.
2026-07-16 06:28:55 +02:00
16 changed files with 917 additions and 126 deletions

View File

@ -9,6 +9,7 @@ using DevExpress.Blazor;
using DevExpress.Data.Filtering;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using System.ComponentModel;
using System.Reflection;
@ -96,11 +97,80 @@ public interface IMgGridBase : IGrid
Task<bool> HasUserLayoutAsync();
/// <summary>
/// Applies a per-field quick filter to the grid — delegates to DevExpress <c>SetFieldFilterCriteria</c>,
/// which AND-combines it with the user's filter row (and other fields' quick filters). Passing
/// <paramref name="criteria"/> = null clears this field's filter. See ADR 0004.
/// Applies a filter-panel per-field criteria to the grid — delegates to DevExpress <c>SetFieldFilterCriteria</c>,
/// which AND-combines it with the other fields' criteria. NOTE: the per-field criteria slot is SHARED with the
/// grid's filter row — the panel only manages criteria its controls recognize as their own (see
/// <see cref="IMgGridFilterPanelComponent.OwnsCriteria"/>). Passing <paramref name="criteria"/> = null clears the
/// field's criteria. See ADR 0004.
/// </summary>
void SetFieldQuickFilter(string fieldName, CriteriaOperator? criteria);
void SetFilterPanelCriteria(string fieldName, CriteriaOperator? criteria);
/// <summary>Whether this grid has a quick-filter panel (i.e. <c>FilterPanel</c> is set).</summary>
bool HasFilterPanel { get; }
/// <summary>Whether the quick-filter panel is currently shown (toggled from the toolbar).</summary>
bool IsFilterPanelVisible { get; }
/// <summary>Shows/hides the quick-filter panel. No-op when the grid has no filter panel.</summary>
void ToggleFilterPanel();
/// <summary>
/// The grid's current DevExpress <c>SizeMode</c> (from <c>DxGrid</c>). Filter-panel components (e.g.
/// <c>MgGridFilterPanelTagBox</c>) inherit it as their default when the consumer didn't set one, so the
/// panel matches the grid's density.
/// </summary>
SizeMode? GridSizeMode { get; }
/// <summary>
/// Reads back a field's current criteria (DevExpress <c>GetFieldFilterCriteria</c>) — the counterpart of
/// <see cref="SetFilterPanelCriteria"/>. Filter-panel controls use it to restore their selection after a
/// persisted layout (which includes the filter criteria) has been applied. See ADR 0004.
/// </summary>
CriteriaOperator? GetFilterPanelCriteria(string fieldName);
/// <summary>
/// Registers a filter-panel component (e.g. <c>MgGridFilterPanelTagBox</c>) so the grid can reconcile it
/// whenever the criteria may change under it (layout restore — incl. a server-roamed layout —, user-layout
/// load, layout reset, panel show/hide). Held weakly — no unregistration needed; the component is reconciled
/// immediately on registration.
/// </summary>
void RegisterFilterPanelComponent(IMgGridFilterPanelComponent component);
/// <summary>
/// Clears every registered filter-panel component's OWN criteria (the grid's filter row is untouched) and
/// empties the components. Wired to the panel's built-in right-aligned Clear button; callable from consumers.
/// </summary>
void ClearFilterPanel();
/// <summary>
/// Re-runs the filter-panel reconcile on demand: every registered component re-reads its field's criteria
/// and re-renders. Call after a late or in-place lookup-data load (e.g. an <c>AcObservableCollection</c>
/// filled via <c>Replace</c>) when a restored selection should (re)resolve against the now-available data.
/// </summary>
void RefreshFilterPanel();
}
/// <summary>
/// Contract for filter components hosted in a grid's <c>FilterPanel</c> (e.g. <c>MgGridFilterPanelTagBox</c>;
/// future dropdown/textbox/checkbox filter components implement the same contract over their own Mg* seams).
/// Registered components are held weakly by the grid, which calls <see cref="SyncFromGrid"/> whenever the grid's
/// criteria may have changed under them, so the panel UI always shows exactly what is filtering the grid
/// (no invisible panel filter). See ADR 0004.
/// </summary>
public interface IMgGridFilterPanelComponent
{
/// <summary>Grid column field this component filters (as in <c>DxGridDataColumn.FieldName</c>).</summary>
string? FieldName { get; }
/// <summary>
/// Whether <paramref name="criteria"/> is this component's own shape (e.g. the <c>IN(FieldName, ...)</c> an
/// <c>MgGridFilterPanelTagBox</c> writes). The per-field criteria slot is SHARED with the grid's filter row —
/// the panel may only clear/manage criteria its component owns; a value typed into the filter row must survive.
/// </summary>
bool OwnsCriteria(CriteriaOperator criteria);
/// <summary>Re-reads this field's criteria from the grid and updates the component's own UI state.</summary>
void SyncFromGrid();
}
public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient> : DxGrid, IMgGridBase, IAsyncDisposable
@ -251,7 +321,6 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
// Body
contentBuilder.OpenElement(12, "div");
contentBuilder.AddAttribute(13, "class", "mg-fullscreen-body");
if (FilterPanel != null) contentBuilder.AddContent(14, FilterPanel);
base.BuildRenderTree(contentBuilder);
contentBuilder.CloseElement(); // body div
@ -263,7 +332,6 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
contentBuilder.OpenElement(0, "div");
contentBuilder.SetKey(_gridRenderKey);
contentBuilder.AddAttribute(1, "style", "display: contents;");
if (FilterPanel != null) contentBuilder.AddContent(2, FilterPanel);
base.BuildRenderTree(contentBuilder);
contentBuilder.CloseElement();
}
@ -294,12 +362,214 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
[Parameter] public string Caption { get; set; } = typeof(TDataItem).Name;
/// <summary>
/// Optional quick-filter panel rendered above the grid (typically a <c>GridFilterPanel</c> hosting
/// <see cref="MgTagBox{TData,TValue}"/> controls). Rendered within this grid's <see cref="IMgGridBase"/>
/// cascade, so the controls can reach this grid and apply per-field filters. See ADR 0004.
/// Optional filter components (typically <c>MgGridFilterPanelTagBox</c>) rendered below the toolbar (composed into the grid's ToolbarTemplate)
/// inside a styled <c>.mg-grid-filter-panel</c> container (the slot wraps the content). Within this grid's <see cref="IMgGridBase"/>
/// cascade, so the components can reach this grid and apply per-field filters. See ADR 0004.
/// </summary>
[Parameter] public RenderFragment? FilterPanel { get; set; }
/// <summary>Initial visibility of the <see cref="FilterPanel"/>; the toolbar toggle flips it at runtime. Default: shown.</summary>
[Parameter] public bool ShowFilterPanel { get; set; } = true;
/// <summary>Text of the filter panel's built-in right-aligned Clear button (see <see cref="IMgGridBase.ClearFilterPanel"/>).</summary>
[Parameter] public string FilterPanelClearButtonText { get; set; } = "Clear Filters";
/// <inheritdoc />
public bool HasFilterPanel => FilterPanel != null;
/// <inheritdoc />
public bool IsFilterPanelVisible { get; private set; } = true;
/// <inheritdoc />
public void ToggleFilterPanel()
{
IsFilterPanelVisible = !IsFilterPanelVisible;
ReconcileFilterPanel(); // hiding clears the panel's OWN filters — nothing may filter invisibly (ADR 0004)
InvokeAsync(StateHasChanged);
}
/// <inheritdoc />
public SizeMode? GridSizeMode => this.SizeMode;
/// <inheritdoc />
public CriteriaOperator? GetFilterPanelCriteria(string fieldName) => GetFieldFilterCriteria(fieldName);
// Registered filter-panel components (FilterPanel content). Weak references: the DevExpress editor seams have
// no public disposal seam to unregister from, and the components live inside this grid's own toolbar template
// anyway — dead entries are pruned on each use.
private readonly List<WeakReference<IMgGridFilterPanelComponent>> _filterPanelComponents = [];
// Set when a restored layout may have brought filter criteria with it (GridPersistentLayout.FilterCriteria);
// processed in OnAfterRenderAsync, AFTER the grid has applied the layout.
private bool _pendingFilterPanelReconcile;
/// <inheritdoc />
public void RegisterFilterPanelComponent(IMgGridFilterPanelComponent component)
{
foreach (var registered in GetRegisteredFilterPanelComponents())
{
if (ReferenceEquals(registered, component))
{
ReconcileFilterPanelComponent(component);
return;
}
}
_filterPanelComponents.Add(new WeakReference<IMgGridFilterPanelComponent>(component));
// Immediate reconcile: a component registering AFTER the layout restore (typical — the panel renders inside
// the toolbar template) picks up the restored criteria right away; before it, the pending reconcile covers it.
ReconcileFilterPanelComponent(component);
}
private List<IMgGridFilterPanelComponent> GetRegisteredFilterPanelComponents()
{
_filterPanelComponents.RemoveAll(weakReference => !weakReference.TryGetTarget(out _));
var live = new List<IMgGridFilterPanelComponent>(_filterPanelComponents.Count);
foreach (var weakReference in _filterPanelComponents)
if (weakReference.TryGetTarget(out var component))
live.Add(component);
return live;
}
/// <summary>
/// Brings every registered filter-panel component in line with the grid's criteria. Visible panel: the
/// components re-read and display their field's criteria (e.g. after a persisted/roamed layout restored it).
/// Hidden panel: the components' OWN criteria are CLEARED — a panel filter must never filter invisibly.
/// Known edge (layout restored where the grid has no FilterPanel at all): MGGRID_ISSUES.md ACBLAZOR-GRID-I-D4T7.
/// </summary>
private void ReconcileFilterPanel()
{
foreach (var component in GetRegisteredFilterPanelComponents())
ReconcileFilterPanelComponent(component);
}
private void ReconcileFilterPanelComponent(IMgGridFilterPanelComponent component)
{
if (!IsFilterPanelVisible) ClearOwnedFilterPanelCriteria(component);
component.SyncFromGrid();
}
/// <summary>
/// Clears the component's field criteria ONLY when the component recognizes it as its own. The per-field
/// criteria slot is SHARED with the grid's filter row (both go through <c>SetFieldFilterCriteria</c>) — a value
/// typed into the filter row (e.g. Contains("EUR")) is visible UI and must survive panel hide / panel clear.
/// </summary>
private void ClearOwnedFilterPanelCriteria(IMgGridFilterPanelComponent component)
{
var fieldName = component.FieldName;
if (string.IsNullOrWhiteSpace(fieldName)) return;
// NOTE: CriteriaOperator overloads == / != (they BUILD criteria) — null checks must be pattern-based.
if (GetFieldFilterCriteria(fieldName) is { } criteria && component.OwnsCriteria(criteria))
SetFieldFilterCriteria(fieldName, null);
}
/// <inheritdoc />
public void ClearFilterPanel()
{
foreach (var component in GetRegisteredFilterPanelComponents())
{
ClearOwnedFilterPanelCriteria(component);
component.SyncFromGrid();
}
InvokeAsync(StateHasChanged); // refresh the built-in Clear button's enabled state
}
/// <inheritdoc />
public void RefreshFilterPanel()
{
ReconcileFilterPanel();
InvokeAsync(StateHasChanged);
}
/// <summary>
/// Whether any registered filter-panel component currently OWNS an active criteria — i.e. the panel is actually
/// filtering. Drives the built-in Clear button's enabled state. Diagnostic by design: if the button is enabled
/// while every panel component looks empty, a panel criteria is active without UI — the invisible-filter bug
/// signature, made visible on purpose.
/// </summary>
public bool HasActiveFilterPanelCriteria
{
get
{
foreach (var component in GetRegisteredFilterPanelComponents())
{
var fieldName = component.FieldName;
if (string.IsNullOrWhiteSpace(fieldName)) continue;
if (GetFieldFilterCriteria(fieldName) is { } criteria && component.OwnsCriteria(criteria))
return true;
}
return false;
}
}
private RenderFragment<GridToolbarTemplateContext>? _consumerToolbarTemplate;
private RenderFragment<GridToolbarTemplateContext>? _wrappedToolbarTemplate;
/// <summary>
/// Renders the <see cref="FilterPanel"/> BELOW the toolbar by composing it into the grid's ToolbarTemplate:
/// the consumer's toolbar first, then the styled <c>.mg-grid-filter-panel</c> container. Re-wraps whenever the
/// consumer supplies a new template instance (Blazor re-applies markup parameters on every SetParametersAsync);
/// no-op when the incoming template is already our wrapper. <see cref="IsFilterPanelVisible"/> is read at render
/// time inside the fragment, so the toolbar toggle works without re-wrapping.
/// COMPILE-VERIFY: ToolbarTemplate is RenderFragment&lt;GridToolbarTemplateContext&gt; on this DevExpress version.
/// </summary>
private void WrapToolbarTemplateWithFilterPanel()
{
if (FilterPanel == null) return;
if (ReferenceEquals(ToolbarTemplate, _wrappedToolbarTemplate)) return;
_consumerToolbarTemplate = ToolbarTemplate;
_wrappedToolbarTemplate = context => builder =>
{
// Wrap toolbar + filter panel in a vertical (column) container so the panel renders BELOW the toolbar,
// not beside it — the grid's toolbar region lays its children out horizontally otherwise.
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "mg-grid-toolbar-with-filter");
// Toolbar row carries the toolbar-container's native padding tokens (the container's own padding is
// zeroed by CSS when it hosts this wrapper) so the toolbar keeps its exact native look while the
// filter band below can span the grid's full width. See .mg-grid-toolbar-row in mg-grid-info-panel.css.
builder.OpenElement(2, "div");
builder.AddAttribute(3, "class", "mg-grid-toolbar-row");
if (_consumerToolbarTemplate != null) builder.AddContent(4, _consumerToolbarTemplate(context));
builder.CloseElement();
// Always rendered, CSS-hidden when toggled off: the controls must EXIST (and stay registered) even
// while hidden — that is how the grid knows WHICH fields are panel-owned when it must clear them
// (hidden panel => no active panel filter; see ReconcileFilterPanelControl).
builder.OpenElement(5, "div");
builder.AddAttribute(6, "class", IsFilterPanelVisible ? "mg-grid-filter-panel" : "mg-grid-filter-panel mg-hidden");
builder.AddContent(7, FilterPanel);
// Built-in right-aligned Clear button — clears the panel's OWN filters only (filter row untouched),
// so users don't have to hide/show the panel to reset it.
builder.OpenComponent<DxButton>(8);
builder.AddAttribute(9, nameof(DxButton.Text), FilterPanelClearButtonText);
builder.AddAttribute(10, nameof(DxButton.RenderStyle), ButtonRenderStyle.Secondary);
builder.AddAttribute(11, nameof(DxButton.RenderStyleMode), ButtonRenderStyleMode.Outline);
builder.AddAttribute(12, nameof(DxButton.SizeMode), this.SizeMode);
builder.AddAttribute(13, nameof(DxButton.CssClass), "mg-grid-filter-panel-clear");
builder.AddAttribute(14, nameof(DxButton.Enabled), HasActiveFilterPanelCriteria);
builder.AddAttribute(15, nameof(DxButton.Click), EventCallback.Factory.Create<MouseEventArgs>(this, _ => ClearFilterPanel()));
builder.CloseComponent();
builder.CloseElement();
builder.CloseElement();
};
ToolbarTemplate = _wrappedToolbarTemplate;
}
/// <summary>
/// Name for auto-saving/loading grid layout. If not set, defaults to "Grid{TDataItem.Name}"
/// </summary>
@ -308,19 +578,25 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
public bool IsMasterGrid => ParentDataItem == null;
protected PropertyInfo? KeyFieldPropertyInfoToParent;
private string? _filterText = null;
private string? _customFilterCriteria = null;
/// <summary>
/// Consumer-supplied base filter criteria (a DevExpress CriteriaOperator string) — propagated to the data source
/// (appended as the LAST positional GetAll parameter) and triggers a reload; the server parses + whitelists it.
/// Renamed FilterText → CustomFilterText → CustomFilterCriteria (2026-07-16) as the semantics settled to a
/// criteria string. Under the ADR-0004 unified model it is AND-merged with the grid's automatic FilterCriteria.
/// </summary>
[Parameter]
public string? FilterText
public string? CustomFilterCriteria
{
get => _filterText;
get => _customFilterCriteria;
set
{
_filterText = value;
_customFilterCriteria = value;
if (_dataSource != null && _dataSource.FilterText != value)
if (_dataSource != null && _dataSource.CustomFilterCriteria != value)
{
_dataSource.FilterText = value;
_dataSource.CustomFilterCriteria = value;
ReloadDataSourceAsync().Forget(Logger);
}
}
@ -406,6 +682,8 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
if (Logger == null)
throw new NullReferenceException($"[{GetType().Name}] Logger == null");
IsFilterPanelVisible = ShowFilterPanel;
if (SignalRClient == null)
{
Logger.Error($"[{GetType().Name}] SignalRClient == null");
@ -415,7 +693,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
var crudTags = new SignalRCrudTags(GetAllMessageTag, GetItemMessageTag, AddMessageTag, UpdateMessageTag, RemoveMessageTag);
_dataSource = (TSignalRDataSource)Activator.CreateInstance(typeof(TSignalRDataSource), SignalRClient, crudTags, Logger, ContextIds)!;
_dataSource.FilterText = FilterText;
_dataSource.CustomFilterCriteria = CustomFilterCriteria;
SetGridData(_dataSource.GetReferenceInnerList());
@ -472,6 +750,14 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// Runs before the data-source block (which may early-return): the filter-panel state is independent of
// the data being loaded, and the restored layout has been applied by this point.
if (_pendingFilterPanelReconcile)
{
_pendingFilterPanelReconcile = false;
ReconcileFilterPanel();
}
if (firstRender)
{
if (_dataSource == null) return;
@ -733,6 +1019,8 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
{
await base.SetParametersAsyncCore(parameters);
WrapToolbarTemplateWithFilterPanel();
if (!IsFirstInitializeParameterCore)
{
//if (typeof(TDataItem) is IId<TId> || typeof(TDataItem) is IId<TId>)
@ -841,6 +1129,11 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
_defaultLayoutJson ??= JsonSerializer.Serialize(SaveLayout());
e.Layout = await LoadLayoutFromLocalStorageAsync(AutomaticLayoutStorageKey);
// The restored layout may carry filter-panel criteria (GridPersistentLayout.FilterCriteria). Reconcile
// AFTER the grid has applied it (next OnAfterRenderAsync): visible panel => controls re-select; hidden
// panel => the panel's own criteria is cleared. See ReconcileFilterPanel / ADR 0004.
if (e.Layout != null) _pendingFilterPanelReconcile = true;
}
private async Task Grid_LayoutAutoSaving(GridPersistentLayoutEventArgs e)
@ -948,6 +1241,10 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
if (layout != null)
{
LoadLayout(layout);
// The loaded (possibly server-roamed) layout may carry filter-panel criteria — reconcile the panel
// controls so what filters the grid is what the panel shows (ADR 0004).
ReconcileFilterPanel();
}
}
@ -963,6 +1260,9 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
if (defaultLayout != null)
LoadLayout(defaultLayout);
}
// The default layout has no filter-panel criteria — the panel controls must empty accordingly.
ReconcileFilterPanel();
}
@ -1022,7 +1322,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
}
/// <inheritdoc />
public void SetFieldQuickFilter(string fieldName, CriteriaOperator? criteria)
public void SetFilterPanelCriteria(string fieldName, CriteriaOperator? criteria)
=> SetFieldFilterCriteria(fieldName, criteria);
/// <summary>

View File

@ -1,32 +0,0 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
namespace AyCode.Blazor.Components.Components.Grids;
/// <summary>
/// Base for a grid quick-filter panel — a styled container placed in a grid's <c>FilterPanel</c> slot,
/// hosting <see cref="MgTagBox{TData,TValue}"/> (or other) per-field quick-filter controls above the grid.
/// <para>
/// Framework base. Projects derive a (typically empty) <c>GridFilterPanel</c> per the Mg* seam policy
/// (cf. <c>MgGridBase</c> → project grid). The grid reference reaches the child controls via the grid's own
/// <see cref="IMgGridBase"/> cascade (<c>MgGridBase</c> provides it) — this panel is layout only, no wiring.
/// </para>
/// </summary>
public class MgGridFilterPanelBase : ComponentBase
{
/// <summary>The quick-filter controls (e.g. <see cref="MgTagBox{TData,TValue}"/>) to render in the panel.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Extra CSS classes appended to the panel container.</summary>
[Parameter] public string? CssClass { get; set; }
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", string.IsNullOrWhiteSpace(CssClass)
? "mg-grid-filter-panel"
: $"mg-grid-filter-panel {CssClass}");
builder.AddContent(2, ChildContent);
builder.CloseElement();
}
}

View File

@ -0,0 +1,365 @@
using DevExpress.Blazor;
using DevExpress.Data.Filtering;
using Microsoft.AspNetCore.Components;
namespace AyCode.Blazor.Components.Components.Grids;
/// <summary>
/// Filter-panel tag box for MgGrid — <see cref="MgTagBox{TData,TValue}"/> (the universal, behavior-free
/// <c>DxTagBox</c> seam) plus the grid filter-panel wiring (<see cref="IMgGridFilterPanelComponent"/>).
/// <para>
/// Place inside a grid's <c>FilterPanel</c>. Changing the selection applies a per-field
/// <c>IN(FieldName, selected...)</c> criteria to the grid via <see cref="IMgGridBase.SetFilterPanelCriteria"/>;
/// DevExpress AND-combines it with the user's filter row (it uses <c>SetFieldFilterCriteria</c>, NOT
/// <c>SetFilterCriteria</c>) — see ADR 0004.
/// </para>
/// The selection is managed internally, so the consumer writes just
/// <c>&lt;MgGridFilterPanelTagBox FieldName="..." Data="..." /&gt;</c> — no <c>@bind-Values</c> / backing field
/// needed (the component OWNS <c>ValuesChanged</c>; do not bind it). Registers as
/// <see cref="IMgGridFilterPanelComponent"/>, so a criteria restored from a persisted layout re-selects the
/// tag box. Defaults its <c>SizeMode</c> to the grid's when the consumer sets none.
/// </summary>
/// <typeparam name="TData">Data item type (as for <c>DxTagBox</c>).</typeparam>
/// <typeparam name="TValue">Selected value type (as for <c>DxTagBox</c>).</typeparam>
public class MgGridFilterPanelTagBox<TData, TValue> : MgTagBox<TData, TValue>, IMgGridFilterPanelComponent
{
/// <summary>
/// Grid column field this filter targets. Consistent with <c>DxGridDataColumn.FieldName</c> and the
/// <c>SetFieldFilterCriteria(fieldName, ...)</c> parameter. When null/empty, no grid filter is applied.
/// </summary>
[Parameter] public string? FieldName { get; set; }
/// <summary>
/// The grid to filter — supplied by the grid's <c>FilterPanel</c> cascade (<c>MgGridBase</c> provides the
/// <see cref="IMgGridBase"/> cascade). Null when the tag box is not inside a grid filter panel.
/// </summary>
[CascadingParameter] public IMgGridBase? Grid { get; set; }
protected override void OnParametersSet()
{
base.OnParametersSet();
// Route the tag box's selection through our handler so a change applies the grid filter.
// (The consumer does not bind Values; the selection is owned internally — see OnSelectionChanged.)
base.ValuesChanged = EventCallback.Factory.Create<IEnumerable<TValue>>(this, OnSelectionChanged);
InheritSizeModeFromGrid();
TrackEditorSnapshot();
//Diag("OnParametersSet");
SyncSelectionToCriteria();
}
// ---- Dormant diagnostics -------------------------------------------------------------------------------
// Console tracing for the restore/heal flow (Blazor WASM: Console.WriteLine lands in the browser console;
// filter by "[MgFP"). The call sites are commented out — uncomment them when tracing is needed again
// (e.g. during the v2 envelope/pull-model work, ADR 0004). This tooling proved the ValuesChanged-drop and
// the fill-timing root causes; keep it.
private static string Fmt(IEnumerable<TValue>? values) => values is null ? "null" : $"[{string.Join(",", values)}]";
private void Diag(string message)
{
string criteria;
try
{
criteria = Grid is not null && !string.IsNullOrWhiteSpace(FieldName)
&& Grid.GetFilterPanelCriteria(FieldName) is { } activeCriteria
? activeCriteria.ToString()
: "null";
}
catch (Exception ex)
{
criteria = $"ERR:{ex.GetType().Name}";
}
var dataCount = Data is null ? -1 : Data.Count();
Console.WriteLine($"[MgFP:{FieldName}] {message} | dataCount={dataCount} snapWasEmpty={_snapshotTakenWhileDataEmpty} " +
$"values={Fmt(Values)} criteria={criteria}");
}
// ---- Editor-snapshot state -----------------------------------------------------------------------------
// A restore has TWO inputs that arrive in arbitrary order: the criteria (async layout restore via JS interop)
// and the lookup Data (async load — routinely filled IN PLACE via AcObservableCollection.Replace on a
// pre-created empty instance). DevExpress snapshots the editor's item list and detects Data changes BY
// REFERENCE ONLY, so an in-place fill leaves the editor holding an EMPTY list and no asserted Values can ever
// resolve into tags.
// This state is LEVEL-triggered by design: it records the standing fact "this editor's snapshot was taken
// while Data was empty" instead of trying to catch the 0->N edge. Edge/one-shot gates were tried twice and
// both systematically stranded the LAST-filled lookup — its arming pass never happens (see ADR 0004).
private IEnumerable<TData>? _snapshotData;
private bool _snapshotTakenWhileDataEmpty;
private bool IsDataEmpty => Data is null || !Data.Any();
// The measured editor-internal reload (a JS hop) settles in ~300 ms; the delay is a generous cover, not a
// contract — the v2 envelope/pull model removes the need for it entirely (see ADR 0004 → planned v2).
private const int HealAssertDelayMs = 750;
private bool _healAssertScheduled;
/// <summary>
/// Records whether the editor's current item snapshot was taken against an EMPTY <c>Data</c>. Runs on every
/// parameter pass REGARDLESS of criteria presence — arming must not depend on the criteria arriving first;
/// that ordering assumption is exactly what stranded the last-filled lookup.
/// </summary>
private void TrackEditorSnapshot()
{
if (!ReferenceEquals(Data, _snapshotData))
{
// A NEW instance is detected by DevExpress, which re-reads the list itself => the editor's snapshot
// matches whatever this instance currently holds.
_snapshotData = Data;
_snapshotTakenWhileDataEmpty = IsDataEmpty;
return;
}
// Same instance, still empty => the snapshot is (still) an empty list, and a later in-place fill will be
// invisible to DevExpress. Same instance, non-empty => leave the flag alone: it answers "was the snapshot
// taken before the fill?", which only a heal may clear.
if (IsDataEmpty) _snapshotTakenWhileDataEmpty = true;
}
/// <summary>
/// Forces the editor to re-read its <c>Data</c> source when its snapshot is known-stale (taken while empty)
/// and <c>Data</c> has since been filled in place. SAFE ORDER (console-proven, 2026-07-17): the reload runs
/// against an EMPTY editor — no value sits in it (see the data-empty gates in <see cref="SyncSelectionToCriteria"/>
/// and <see cref="SyncFromGrid"/>) — so the editor's async revalidation has nothing to drop and raises no
/// spurious <c>ValuesChanged</c>. The selection is then asserted ONCE, delayed, into the settled editor
/// (<see cref="ScheduleHealAssert"/>); its echo rewrites the same criteria, which is harmless.
/// </summary>
private void HealEditorSnapshotIfStale()
{
if (!_snapshotTakenWhileDataEmpty || IsDataEmpty) return;
_snapshotTakenWhileDataEmpty = false;
//Diag(">>> HEAL: calling DevExpress Reload() on an EMPTY editor");
Reload();
//Diag("<<< HEAL: Reload() returned");
ScheduleHealAssert();
}
/// <summary>
/// Schedules the ONE selection assert that follows a heal, delayed until the editor's internal reload has
/// settled. Re-reads the criteria AT FIRE TIME — if it is gone by then (user cleared it, panel hidden), no-op.
/// </summary>
private void ScheduleHealAssert()
{
if (_healAssertScheduled) return;
_healAssertScheduled = true;
_ = InvokeAsync(async () =>
{
try
{
await Task.Delay(HealAssertDelayMs);
_healAssertScheduled = false;
if (Grid is null || string.IsNullOrWhiteSpace(FieldName) || IsDataEmpty) return;
if (Grid.GetFilterPanelCriteria(FieldName) is not { } criteria) return;
if (!TryExtractInValues(criteria, out var values)) return;
//Diag($"HEAL-assert (delayed {HealAssertDelayMs} ms) -> {Fmt(values)}");
SetValuesCore(values);
StateHasChanged();
}
catch
{
// Best-effort: a failed delayed assert leaves the box empty — the criteria is untouched and the
// Clear button still signals it; a later reconcile/parameter pass re-asserts.
//Diag("HEAL-assert FAILED");
_healAssertScheduled = false;
}
});
}
/// <summary>
/// Brings the displayed selection in line with our field's active criteria. GATE: while <c>Data</c> is empty
/// NOTHING is asserted — an unresolvable value sitting in the editor is exactly the ammunition the editor's
/// async revalidation needs to raise a spurious empty <c>ValuesChanged</c> (console-proven to destroy the
/// criteria). The criteria itself stays untouched meanwhile (rows filter, the Clear button signals it); the
/// assert happens once the data is there — immediately when no heal was needed, delayed after a heal.
/// </summary>
private void SyncSelectionToCriteria()
{
if (Grid is null || string.IsNullOrWhiteSpace(FieldName)) return;
if (Grid.GetFilterPanelCriteria(FieldName) is not { } criteria) return;
if (!TryExtractInValues(criteria, out var criteriaValues)) return;
if (IsDataEmpty) return; // no value may enter an empty editor
HealEditorSnapshotIfStale(); // reloads the still-empty editor + schedules the delayed assert
if (_healAssertScheduled) return; // the delayed assert owns the write — don't race the editor's reload
var drifted = Values is null || !Values.SequenceEqual(criteriaValues);
if (drifted) SetValuesCore(criteriaValues);
}
/// <summary>
/// Defaults <c>SizeMode</c> to the grid's (via the <see cref="Grid"/> cascade) so the filter panel matches the
/// grid's density — but only when the consumer left it unset (<c>null</c>); an explicit consumer value wins.
/// Once inherited, <c>SizeMode</c> is non-null, so this defaults once rather than overriding every render.
/// Imperative parameter writes on a DevExpress editor must be wrapped in <c>BeginUpdate</c>/<c>EndUpdate</c>
/// (same reason as <c>Values</c> in <see cref="SetValuesCore"/>).
/// </summary>
private void InheritSizeModeFromGrid()
{
// SizeMode != null => the consumer set it (or we already inherited it) => leave it alone.
if (this.SizeMode is not null || Grid?.GridSizeMode is not { } gridSizeMode) return;
BeginUpdate();
try
{
this.SizeMode = gridSizeMode;
}
finally
{
EndUpdate();
}
}
protected override void OnAfterRender(bool firstRender)
{
base.OnAfterRender(firstRender);
// Register once, after the first render (grid API calls are unsafe mid-render). The grid holds the
// registration weakly and reconciles this component immediately — restoring a persisted panel filter
// into the UI, or clearing it while the panel is hidden. See IMgGridFilterPanelComponent / ADR 0004.
if (firstRender)
{
//Diag("OnAfterRender(firstRender) -> RegisterFilterPanelComponent");
Grid?.RegisterFilterPanelComponent(this);
}
}
private void OnSelectionChanged(IEnumerable<TValue>? values)
{
//Diag($"*** ValuesChanged CALLBACK — incoming={Fmt(values)}");
SetValuesCore(values);
ApplyFilterPanelCriteria(values);
}
private void ApplyFilterPanelCriteria(IEnumerable<TValue>? values)
{
if (Grid is null || string.IsNullOrWhiteSpace(FieldName)) return;
var selected = values?.Where(v => v is not null).Cast<object>().ToArray() ?? [];
// Empty selection => null => clears this field's criteria. Otherwise IN(FieldName, selected...).
CriteriaOperator? criteria = selected.Length > 0 ? new InOperator(FieldName, selected) : null;
// OWNERSHIP: an empty selection may only clear OUR criteria. The per-field slot is shared with the
// grid's filter row — a not-owned criteria (e.g. Contains("EUR") typed there) must survive any empty
// ValuesChanged, be it a genuine user clear (nothing of ours is active anyway) or the echo of a
// programmatic clear.
if (criteria is null
&& Grid.GetFilterPanelCriteria(FieldName) is { } current && !OwnsCriteria(current))
{
//Diag("WRITE skipped — empty selection over a NOT-OWNED criteria (filter-row value protected)");
return;
}
//Diag(criteria is null
// ? "!!! WRITE criteria -> NULL (CLEARING the field's criteria)"
// : $"WRITE criteria -> {criteria}");
Grid.SetFilterPanelCriteria(FieldName, criteria);
}
/// <inheritdoc />
public bool OwnsCriteria(CriteriaOperator criteria) => TryExtractInValues(criteria, out _);
/// <inheritdoc />
public void SyncFromGrid()
{
if (Grid is null || string.IsNullOrWhiteSpace(FieldName)) return;
// NOTE: CriteriaOperator overloads == / != (they BUILD criteria) — null checks must be pattern-based.
var criteria = Grid.GetFilterPanelCriteria(FieldName);
//Diag($"SyncFromGrid (from grid reconcile) — criteria={(criteria is null ? "null" : criteria.ToString())}");
if (criteria is not null && TryExtractInValues(criteria, out var values))
{
// Same gate as SyncSelectionToCriteria: no value may enter an empty editor — the criteria stays
// untouched and the assert happens once the lookup data exists (this is also what made the consumer's
// RefreshFilterPanel call after an in-place fill actually work: the heal below reloads the editor).
if (IsDataEmpty) return;
HealEditorSnapshotIfStale();
if (!_healAssertScheduled) SetValuesCore(values);
}
else
{
// Null criteria, or a criteria that is not ours: the per-field slot is SHARED with the grid's filter
// row (e.g. Contains("EUR") typed into the column filter) — leave it untouched (it is visible in the
// grid's own filter UI, so it is not invisible filtering) and just show no selection. Only write when
// there is something to clear: a null-over-null write can still echo a spurious ValuesChanged.
if (Values is not null && Values.Any()) SetValuesCore(null);
}
// May be called from the grid (outside this component's render path) — request our own re-render.
InvokeAsync(StateHasChanged);
}
/// <summary>
/// DevExpress tracks its parameters; setting <c>Values</c> imperatively (outside markup — event callback,
/// grid-initiated sync) must be wrapped in <c>BeginUpdate</c>/<c>EndUpdate</c> — otherwise DevExpress throws
/// "A parameter value is specified outside a component's markup."
/// </summary>
private void SetValuesCore(IEnumerable<TValue>? values)
{
BeginUpdate();
try
{
Values = values;
}
finally
{
EndUpdate();
}
}
/// <summary>
/// Recognizes this component's own criteria shape — <c>IN(FieldName, v1, v2...)</c> — and extracts the values,
/// converting them to <typeparamref name="TValue"/> (a layout round-trip may bring e.g. Int32 for an int
/// column back through criteria parsing). Any other shape returns false.
/// </summary>
private bool TryExtractInValues(CriteriaOperator criteria, out List<TValue> values)
{
values = [];
if (criteria is not InOperator inOperator) return false;
if (inOperator.LeftOperand is not OperandProperty property
|| !string.Equals(property.PropertyName, FieldName, StringComparison.Ordinal)) return false;
var targetType = Nullable.GetUnderlyingType(typeof(TValue)) ?? typeof(TValue);
foreach (var operand in inOperator.Operands)
{
if (operand is not OperandValue { Value: { } raw }) return false;
if (raw is TValue typed)
{
values.Add(typed);
continue;
}
try
{
values.Add((TValue)Convert.ChangeType(raw, targetType));
}
catch
{
return false;
}
}
return values.Count > 0;
}
}

View File

@ -30,6 +30,7 @@
</Items>
</DxToolbarItem>
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Reload data")" BeginGroup="true" Click="ReloadData_Click" IconCssClass="grid-refresh" Enabled="@(!IsSyncing && !_isReloadInProgress && !IsEditing)" />
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : FilterPanelButtonText)" Click="FilterPanel_Click" IconCssClass="grid-filter" Visible="@Grid.HasFilterPanel" Enabled="@(!IsEditing)" />
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : FullscreenButtonText)" Click="Fullscreen_Click" IconCssClass="@FullscreenIconCssClass" Enabled="@(!IsEditing)" />
@ToolbarItemsExtended
}
@ -82,6 +83,16 @@
/// </summary>
private string FullscreenIconCssClass => IsFullscreenMode ? "grid-fullscreen-exit" : "grid-fullscreen";
/// <summary>
/// Whether the quick-filter panel is currently shown
/// </summary>
private bool IsFilterPanelVisible => Grid?.IsFilterPanelVisible ?? false;
/// <summary>
/// Button text for the filter-panel toggle
/// </summary>
private string FilterPanelButtonText => IsFilterPanelVisible ? "Hide Filters" : "Show Filters";
protected override async Task OnInitializedAsync()
{
_hasUserLayout = await Grid.HasUserLayoutAsync();
@ -145,6 +156,11 @@
Grid.ToggleFullscreen();
}
void FilterPanel_Click()
{
Grid.ToggleFilterPanel();
}
async Task ExportXlsxItem_Click()
{
await Grid.ExportToXlsxAsync(ExportFileName);

View File

@ -4,10 +4,10 @@ Core grid system built on DevExpress `DxGrid`. For the full technical reference
## 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.
- **`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, and a quick-filter `FilterPanel` slot (renders `MgTagBox` controls below the toolbar in a styled `.mg-grid-filter-panel` container; toolbar Show/Hide 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`.
- **`MgGridToolbarTemplate.razor`** — Full toolbar template: New/Edit/Delete/Save/Cancel, row navigation, layout menu (Load/Save/Reset), column chooser, export, reload, filter-panel toggle (visible only when the grid has a `FilterPanel`), 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.

View File

@ -1,77 +1,15 @@
using AyCode.Blazor.Components.Components.Grids;
using DevExpress.Blazor;
using DevExpress.Data.Filtering;
using Microsoft.AspNetCore.Components;
namespace AyCode.Blazor.Components.Components;
/// <summary>
/// <c>DxTagBox</c> seam (per the Mg* "derive every DevExpress component" policy) with an added grid
/// quick-filter capability.
/// <para>
/// When placed inside a grid's <c>FilterPanel</c>, changing the selection applies a per-field
/// <c>IN(FieldName, selected...)</c> filter to the grid via <see cref="IMgGridBase.SetFieldQuickFilter"/>.
/// DevExpress AND-combines it with the user's filter row (it uses <c>SetFieldFilterCriteria</c>, NOT
/// <c>SetFilterCriteria</c>) — see ADR 0004.
/// </para>
/// The selection is managed internally, so the consumer writes just
/// <c>&lt;MgTagBox FieldName="..." Data="..." /&gt;</c> — no <c>@bind-Values</c> / backing field needed.
/// Outside a grid filter panel (no <see cref="Grid"/> cascade) it behaves as a plain tag box.
/// <c>DxTagBox</c> seam (per the Mg* "derive every DevExpress component" policy). Deliberately behavior-free:
/// universal, usable anywhere a <c>DxTagBox</c> fits — do NOT wire grid/filter-panel (or any other
/// feature-specific) behavior into this seam. The MgGrid filter-panel tag box is the derived
/// <c>MgGridFilterPanelTagBox</c> (Components/Grids).
/// </summary>
/// <typeparam name="TData">Data item type (as for <c>DxTagBox</c>).</typeparam>
/// <typeparam name="TValue">Selected value type (as for <c>DxTagBox</c>).</typeparam>
public class MgTagBox<TData, TValue> : DxTagBox<TData, TValue>
{
/// <summary>
/// Grid column field this quick-filter targets. Consistent with <c>DxGridDataColumn.FieldName</c> and the
/// <c>SetFieldFilterCriteria(fieldName, ...)</c> parameter. When null/empty, no grid filter is applied.
/// </summary>
[Parameter] public string? FieldName { get; set; }
/// <summary>
/// The grid to filter — supplied by the grid's <c>FilterPanel</c> cascade (<c>MgGridBase</c> provides the
/// <see cref="IMgGridBase"/> cascade). Null when the tag box is not inside a grid filter panel.
/// </summary>
[CascadingParameter] public IMgGridBase? Grid { get; set; }
private bool _initialized;
private IEnumerable<TValue>? _selected;
protected override void OnParametersSet()
{
base.OnParametersSet();
// Own the selection internally so the consumer needs no @bind-Values.
// COMPILE-VERIFY (DevExpress version specific): Blazor re-applies base.Values / base.ValuesChanged from the
// consumer markup on every parameter set, so we re-assert ours here each time. First set adopts any initial Values.
if (!_initialized)
{
_selected = Values;
_initialized = true;
}
base.Values = _selected;
base.ValuesChanged = EventCallback.Factory.Create<IEnumerable<TValue>>(this, OnSelectionChanged);
}
private void OnSelectionChanged(IEnumerable<TValue>? values)
{
_selected = values;
base.Values = values; // reflect the new selection in the editor
ApplyQuickFilter(values);
StateHasChanged();
}
private void ApplyQuickFilter(IEnumerable<TValue>? values)
{
if (Grid is null || string.IsNullOrWhiteSpace(FieldName)) return;
var selected = values?.Where(v => v is not null).Cast<object>().ToArray() ?? [];
// COMPILE-VERIFY: confirm the InOperator(string, IEnumerable / params object[]) overload on your
// DevExpress.Data version. Empty selection => null => clears this field's filter.
CriteriaOperator? criteria = selected.Length > 0 ? new InOperator(FieldName, selected) : null;
Grid.SetFieldQuickFilter(FieldName, criteria);
}
}

View File

@ -1,6 +1,6 @@
# 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.
DevExpress component wrappers and grid infrastructure for the AyCode Blazor component library. Each wrapper class extends a DevExpress Blazor control to allow project-wide customization from a single point (`Mg*` = current prefix; `Ac*` = legacy prefix, kept as-is).
## Key Files
@ -13,6 +13,7 @@ DevExpress component wrappers and grid infrastructure for the AyCode Blazor comp
- **`AcMaskedInput.cs`** -- Generic wrapper for `DxMaskedInput<T>`.
- **`AcMemo.cs`** -- Extends `DxMemo`.
- **`AcSpinEdit.cs`** -- Generic wrapper for `DxSpinEdit<T>`.
- **`MgTagBox.cs`** -- Generic wrapper for `DxTagBox<TData, TValue>` with grid quick-filter support: a `FieldName` parameter + the grid cascade let a selection apply a per-field `IN` filter via `IMgGridBase.SetFieldQuickFilter` (see `Grids/` and `docs/MGGRID/MGGRID_PARAMETERS.md` → Quick-Filter Panel).
- **`MgComponentsHelper.cs`** -- Placeholder helper class (currently empty).
## Subfolders

View File

@ -9,7 +9,7 @@
├── Validate Logger, SignalRClient (throw if null)
├── Create SignalRCrudTags from message tag parameters
├── Create TSignalRDataSource via Activator.CreateInstance(SignalRClient, crudTags, ContextIds)
├── Set DataSource.FilterText
├── Set DataSource.CustomFilterCriteria
├── Bind grid Data to data source inner list
└── Subscribe to: OnDataSourceLoaded, OnDataSourceItemChanged, OnSyncingStateChanged

View File

@ -61,6 +61,37 @@ so both the master and the built-in InfoPanel toolbar respect a single consumer
### Related TODO
None yet — becomes a TODO when scheduled.
## ACBLAZOR-GRID-I-D4T7: Roamed layout with quick-filter criteria cannot be cleared where the grid has no FilterPanel
**Severity:** Minor (rare version-/markup-skew scenario) · **Status:** Open · **Area:** `Components/Grids/MgGridBase.cs` (filter-panel reconcile) + layout roaming (`MGGRID_LAYOUT.md`)
### Description
Filter-panel criteria persist inside the grid layout (`GridPersistentLayout.FilterCriteria`) — including the
server-roamed UserSave tier. On restore the grid **reconciles** the registered `FilterPanel` controls: visible
panel → the controls re-select from the criteria; hidden panel → the controls' own criteria is cleared (a panel
filter must never filter invisibly). BUT the reconcile identifies panel-owned fields **from the registered
controls**. If a layout saved by a grid WITH a `FilterPanel` is loaded where the same grid has NO panel at all
(older client build, or a page variant without the panel), no controls register → the panel criteria stays
applied invisibly (shape alone is not enough to strip it safely — an `IN` can also come from legitimate
header-filter use).
### Root cause
Field ownership ("this criteria belongs to a filter-panel component") exists only at runtime via component
registration + shape recognition (`IMgGridFilterPanelComponent.OwnsCriteria`); the persisted layout does not
record which fields were panel-owned.
### Known workaround
Reset layout (toolbar) on the affected client, or re-save the layout from a client that has the panel.
### Proposed fix
Wrap the persisted layout in an MgGrid envelope (`MgGridLayoutSave { Version, GridLayoutJson,
FilterPanelLayoutJson }`) so panel state never rides inside the DevExpress layout: an entry no component claims
simply never reaches the grid — this issue is then closed **by construction**, no clearing logic needed. Design in
ADR 0004 (AyCode.Core repo) → "Planned v2".
### Related TODO
`MGGRID_TODO.md#acblazor-grid-t-w8q3` (the envelope + pull-model restore — closes this issue).
## Issue entry template
```

View File

@ -59,7 +59,7 @@ using the framework tags defined in `AcSignalRTags` (AyCode.Services):
## 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`.
The layout (`GridPersistentLayout`) includes: column order, column widths, sort descriptors, group descriptors, filter row values, filter criteria (`FilterCriteria` — including the filter panel's `IN` criteria; on restore the grid reconciles the `FilterPanel` controls so restored panel filters re-select, see `MGGRID_PARAMETERS.md` → Filter Panel → Persistence & visibility), page size — serialized as JSON via `System.Text.Json`.
## User Identification

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -15,6 +15,7 @@ The standard toolbar rendered inside grid's `ToolbarTemplate`. Provides all stan
| **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` |
| **Filter panel** | Show Filters / Hide Filters (toggles the `FilterPanel`; hiding clears the panel's own filters — the panel also has a built-in Clear button) | When `OnlyGridEditTools=false` AND `Grid.HasFilterPanel` |
### Parameters
@ -37,3 +38,4 @@ The standard toolbar rendered inside grid's `ToolbarTemplate`. Provides all stan
| `IsSyncing` | `Grid.IsSyncing` |
| `HasFocusedRow` | `Grid.GetFocusedRowIndex() >= 0` |
| `IsFullscreenMode` | `Grid.IsFullscreen` |
| `IsFilterPanelVisible` | `Grid.IsFilterPanelVisible` |

View File

@ -14,12 +14,13 @@
- **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`
- **Filter panel**`FilterPanel` slot below the toolbar hosting `MgGridFilterPanelTagBox` components (`IMgGridFilterPanelComponent`); per-field criteria AND-combined by DevExpress with the user's filter row; toolbar Show/Hide toggle + built-in right-aligned Clear button (see `MGGRID_PARAMETERS.md` → Filter Panel; design: AyCode.Core repo `docs/adr/0004-signalr-datasource-server-side-filtering.md`)
## Detailed Documentation
| File | Topics |
|---|---|
| `MGGRID_PARAMETERS.md` | Component parameters (required, CRUD tags, data & context, display), event callbacks, default grid settings |
| `MGGRID_PARAMETERS.md` | Component parameters (required, CRUD tags, data & context, display), quick-filter panel (`FilterPanel` slot + `MgTagBox`), 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 |
@ -96,6 +97,15 @@ The public contract exposed to companion components (toolbar, InfoPanel, wrapper
| `IsFullscreen` | `bool` | Current fullscreen state |
| `AutomaticLayoutStorageKey` | `string` | Current auto-save storage key |
| `ToggleFullscreen()` | `void` | Toggle fullscreen mode |
| `SetFilterPanelCriteria(fieldName, criteria)` | `void` | Apply/clear a filter-panel per-field criteria (delegates to DevExpress `SetFieldFilterCriteria`; `null` clears the field; the per-field slot is shared with the filter row) |
| `HasFilterPanel` | `bool` | Whether the grid has a `FilterPanel` (drives toolbar-toggle visibility) |
| `IsFilterPanelVisible` | `bool` | Current quick-filter panel visibility |
| `ToggleFilterPanel()` | `void` | Show/hide the quick-filter panel |
| `GridSizeMode` | `SizeMode?` | The grid's DevExpress `SizeMode`; filter-panel components inherit it as their default when the consumer sets none |
| `GetFilterPanelCriteria(fieldName)` | `CriteriaOperator?` | Reads back a field's criteria (counterpart of `SetFilterPanelCriteria`; used to restore components after a persisted layout applied) |
| `RegisterFilterPanelComponent(component)` | `void` | Registers an `IMgGridFilterPanelComponent` for criteria reconcile (weakly held, synced immediately; hidden panel ⇒ the components' OWN criteria are cleared) |
| `ClearFilterPanel()` | `void` | Clears every registered component's OWN criteria + empties the components (filter row untouched); wired to the panel's built-in Clear button (enabled only while a panel-owned criteria is active) |
| `RefreshFilterPanel()` | `void` | Re-runs the filter-panel reconcile on demand — call after a late/in-place lookup-data load (e.g. `AcObservableCollection.Replace`) so restored selections resolve against the arrived data |
| `SaveUserLayoutAsync()` | `Task` | Save layout manually |
| `LoadUserLayoutAsync()` | `Task` | Load manually saved layout |
| `ResetLayoutAsync()` | `Task` | Reset to default layout |

View File

@ -5,6 +5,66 @@
These are defined on .dxbl-theme-fluent class and inherited by child elements.
*/
/* Grid toolbar + quick-filter column: stacks the filter panel BELOW the toolbar
(the grid's toolbar region lays its children out horizontally otherwise) */
.mg-grid-toolbar-with-filter {
display: flex;
flex-direction: column;
width: 100%;
}
/* Grid quick-filter panel composed into the grid's ToolbarTemplate, so it physically lives INSIDE
.dxbl-grid-toolbar-container. Styled to read as its OWN band below the toolbar, aligned with the native
group panel ("Drag a column header...") band: it reuses the grid's OWN group-panel padding tokens, which
the grid root redefines per SizeMode so the band's left edge and height track the group panel exactly
at every grid size. Separator above = our border-top (grid border tokens); separator below = the toolbar
container's own native border-bottom (its padding is zeroed below, so that border sits directly under
this band one line, no double border, no extra gap). */
.mg-grid-filter-panel {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--DS-sizing-80, 0.5rem);
padding: var(--dxbl-grid-group-panel-container-padding-y, 0.5rem) var(--dxbl-grid-group-panel-container-padding-x, 0.75rem);
border-top: var(--dxbl-grid-border-width, 1px) var(--dxbl-grid-border-style, solid) var(--dxbl-grid-border-color, #dee2e6);
}
/* The toolbar container's own padding wrapped BOTH rows (toolbar + filter band), which broke the band
illusion: the band's separators stopped short of the grid's edges, and below the band the container's
leftover bottom padding + its native border produced a second line and an asymmetric gap. Zero the
container's padding when it hosts our stacked wrapper; each row then supplies its own padding. */
.dxbl-grid-toolbar-container:has(.mg-grid-toolbar-with-filter) {
padding: 0;
}
/* Toolbar row inside the wrapper: carries the toolbar-container's native padding tokens (SizeMode-adaptive),
so the toolbar itself looks exactly as it does without a filter panel. */
.mg-grid-toolbar-row {
padding: var(--dxbl-grid-toolbar-container-padding-y, 0.5rem) var(--dxbl-grid-toolbar-container-padding-x, 0.75rem);
}
/* Quick-filter controls: sit side by side and share the row width; wrap to the next line only when
they can't fit. Overrides the editors' default (near) full-width so they don't stack one-per-row.
flex-basis / min / max are tunable if a different density is wanted. */
.mg-grid-filter-panel > * {
flex: 1 1 240px;
min-width: 200px;
max-width: 480px;
}
/* Built-in Clear button: right-aligned, natural size — exempt from the adaptive control sizing above. */
.mg-grid-filter-panel > .mg-grid-filter-panel-clear {
flex: 0 0 auto;
min-width: 0;
margin-left: auto;
}
/* Toggled-off state: the panel stays in the render tree (controls must stay alive & registered so the grid
can reconcile/clear their criteria no invisible panel filter), only its box is hidden. */
.mg-grid-filter-panel.mg-hidden {
display: none;
}
/* Main panel - uses DevExpress Design System variables */
.mg-grid-info-panel {
background-color: var(--DS-color-surface-neutral-subdued-rest, #f8f9fa);

File diff suppressed because one or more lines are too long