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.
This commit is contained in:
Loretta 2026-07-17 05:34:01 +02:00
parent 1e518d9a1e
commit b06d321bfb
10 changed files with 523 additions and 124 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,13 @@ 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; }
@ -112,11 +115,62 @@ public interface IMgGridBase : IGrid
void ToggleFilterPanel();
/// <summary>
/// The grid's current DevExpress <c>SizeMode</c> (from <c>DxGrid</c>). Quick-filter controls in the
/// <c>FilterPanel</c> (e.g. <see cref="MgTagBox{TData,TValue}"/>) inherit it as their default when the
/// consumer didn't set one, so the panel matches the grid's density.
/// 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
@ -308,15 +362,18 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
[Parameter] public string Caption { get; set; } = typeof(TDataItem).Name;
/// <summary>
/// Optional quick-filter controls (typically <see cref="MgTagBox{TData,TValue}"/>) rendered below the toolbar (composed into the grid's ToolbarTemplate)
/// 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 controls can reach this grid and apply per-field filters. See ADR 0004.
/// 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;
@ -327,12 +384,132 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
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;
@ -366,13 +543,26 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
if (_consumerToolbarTemplate != null) builder.AddContent(4, _consumerToolbarTemplate(context));
builder.CloseElement();
if (IsFilterPanelVisible)
{
builder.OpenElement(5, "div");
builder.AddAttribute(6, "class", "mg-grid-filter-panel");
builder.AddContent(7, FilterPanel);
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();
};
@ -560,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;
@ -931,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)
@ -1038,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();
}
}
@ -1053,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();
}
@ -1112,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

@ -0,0 +1,212 @@
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();
// Keep the selection in step with our field's active criteria on EVERY parameter pass. The editor
// resolves its tags against the CURRENT Data, and the criteria (layout restore via JS interop) and the
// lookup Data (async load) arrive in arbitrary order — one-shot "did X change" gates proved to get
// consumed in the wrong interleaving and strand an applied-but-invisible selection. This form is
// idempotent: it re-asserts Values (fresh instance => tags re-resolve) whenever the Data instance
// changed or the displayed selection drifted from the criteria, and no-ops otherwise.
if (Grid is not null && !string.IsNullOrWhiteSpace(FieldName)
&& Grid.GetFilterPanelCriteria(FieldName) is { } activeCriteria
&& TryExtractInValues(activeCriteria, out var criteriaValues))
{
// Content signature, not just reference: consumers routinely bind a pre-created EMPTY collection and
// fill the SAME instance later (AcObservableCollection.Replace) — the reference never changes, only
// the count does (0 => N). Count() is O(1) for ICollection-backed lookups.
var dataCount = Data?.Count() ?? -1;
var dataChanged = !ReferenceEquals(Data, _lastSyncedData) || dataCount != _lastSyncedDataCount;
var selectionDrifted = Values is null || !Values.SequenceEqual(criteriaValues);
if (dataChanged || selectionDrifted)
{
_lastSyncedData = Data;
_lastSyncedDataCount = dataCount;
SetValuesCore(criteriaValues);
}
}
}
// Last Data instance + count a re-assert ran against — only consumed when an assignment actually happens
// (see OnParametersSet; a change while there is no active criteria must NOT count as handled).
private IEnumerable<TData>? _lastSyncedData;
private int _lastSyncedDataCount = -1;
/// <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) Grid?.RegisterFilterPanelComponent(this);
}
private void OnSelectionChanged(IEnumerable<TValue>? 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;
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);
if (criteria is not null && TryExtractInValues(criteria, out var values))
{
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.
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

@ -1,100 +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; }
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();
}
/// <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="OnSelectionChanged"/>).
/// </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();
}
}
private void OnSelectionChanged(IEnumerable<TValue>? values)
{
// DevExpress tracks its parameters; setting Values imperatively (outside markup, e.g. from this event
// callback) must be wrapped in BeginUpdate/EndUpdate — otherwise DevExpress throws
// "A parameter value is specified outside a component's markup."
BeginUpdate();
try
{
Values = values;
}
finally
{
EndUpdate();
}
ApplyQuickFilter(values);
}
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() ?? [];
// Empty selection => null => clears this field's filter. Otherwise IN(FieldName, selected...).
CriteriaOperator? criteria = selected.Length > 0 ? new InOperator(FieldName, selected) : null;
Grid.SetFieldQuickFilter(FieldName, criteria);
}
}

View File

@ -61,6 +61,36 @@ 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 (`{ dxLayout, quickFilterFields[] }`) so the loader can clear
quick-filter fields even with no registered controls. Deliberately deferred until the scenario is actually hit
(YAGNI); referenced from ADR 0004 (AyCode.Core repo).
### Related TODO
None yet.
## 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

View File

@ -15,7 +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` |
| **Quick-filter** | Show Filters / Hide Filters (toggles the `FilterPanel`) | When `OnlyGridEditTools=false` AND `Grid.HasFilterPanel` |
| **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

View File

@ -14,7 +14,7 @@
- **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`
- **Quick-filter panel** — `FilterPanel` slot below the toolbar hosting `MgTagBox` controls; per-field criteria AND-combined by DevExpress with the user's filter row; toolbar Show/Hide toggle (see `MGGRID_PARAMETERS.md`Quick-Filter Panel; design: AyCode.Core repo `docs/adr/0004-signalr-datasource-server-side-filtering.md`)
- **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
@ -97,11 +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 |
| `SetFieldQuickFilter(fieldName, criteria)` | `void` | Apply/clear a per-field quick-filter (delegates to DevExpress `SetFieldFilterCriteria`; `null` criteria clears the field) |
| `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`; `MgTagBox` in the `FilterPanel` inherits it as its default when the consumer sets none |
| `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

@ -52,6 +52,19 @@
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