Compare commits

..

No commits in common. "11d1ae1d17462ea5ed62d120d0156615c4f29f65" and "4eb0d1566c350803a88dd6ff9864dc78a78ea812" have entirely different histories.

16 changed files with 126 additions and 917 deletions

View File

@ -9,7 +9,6 @@ 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;
@ -97,80 +96,11 @@ public interface IMgGridBase : IGrid
Task<bool> HasUserLayoutAsync();
/// <summary>
/// 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.
/// 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.
/// </summary>
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();
void SetFieldQuickFilter(string fieldName, CriteriaOperator? criteria);
}
public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient> : DxGrid, IMgGridBase, IAsyncDisposable
@ -321,6 +251,7 @@ 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
@ -332,6 +263,7 @@ 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();
}
@ -362,214 +294,12 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
[Parameter] public string Caption { get; set; } = typeof(TDataItem).Name;
/// <summary>
/// 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.
/// 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.
/// </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>
@ -578,25 +308,19 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
public bool IsMasterGrid => ParentDataItem == null;
protected PropertyInfo? KeyFieldPropertyInfoToParent;
private string? _customFilterCriteria = null;
private string? _filterText = 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? CustomFilterCriteria
public string? FilterText
{
get => _customFilterCriteria;
get => _filterText;
set
{
_customFilterCriteria = value;
_filterText = value;
if (_dataSource != null && _dataSource.CustomFilterCriteria != value)
if (_dataSource != null && _dataSource.FilterText != value)
{
_dataSource.CustomFilterCriteria = value;
_dataSource.FilterText = value;
ReloadDataSourceAsync().Forget(Logger);
}
}
@ -682,8 +406,6 @@ 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");
@ -693,7 +415,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.CustomFilterCriteria = CustomFilterCriteria;
_dataSource.FilterText = FilterText;
SetGridData(_dataSource.GetReferenceInnerList());
@ -750,14 +472,6 @@ 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;
@ -1019,8 +733,6 @@ 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>)
@ -1129,11 +841,6 @@ 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)
@ -1241,10 +948,6 @@ 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();
}
}
@ -1260,9 +963,6 @@ 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();
}
@ -1322,7 +1022,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
}
/// <inheritdoc />
public void SetFilterPanelCriteria(string fieldName, CriteriaOperator? criteria)
public void SetFieldQuickFilter(string fieldName, CriteriaOperator? criteria)
=> SetFieldFilterCriteria(fieldName, criteria);
/// <summary>

View File

@ -0,0 +1,32 @@
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

@ -1,365 +0,0 @@
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,7 +30,6 @@
</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
}
@ -83,16 +82,6 @@
/// </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();
@ -156,11 +145,6 @@
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, and a quick-filter `FilterPanel` slot (renders `MgTagBox` controls below the toolbar in a styled `.mg-grid-filter-panel` container; toolbar Show/Hide 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.
- **`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, filter-panel toggle (visible only when the grid has a `FilterPanel`), 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, 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,15 +1,77 @@
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). 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).
/// <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.
/// </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 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).
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
@ -13,7 +13,6 @@ 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.CustomFilterCriteria
├── Set DataSource.FilterText
├── Bind grid Data to data source inner list
└── Subscribe to: OnDataSourceLoaded, OnDataSourceItemChanged, OnSyncingStateChanged

View File

@ -61,37 +61,6 @@ 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, 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`.
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

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,7 +15,6 @@ 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
@ -38,4 +37,3 @@ 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,13 +14,12 @@
- **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), quick-filter panel (`FilterPanel` slot + `MgTagBox`), event callbacks, default grid settings |
| `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 |
@ -97,15 +96,6 @@ 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,66 +5,6 @@
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