From b06d321bfb21510efb36bc9b4861cfb299edee99 Mon Sep 17 00:00:00 2001 From: Loretta Date: Fri, 17 Jul 2026 05:34:01 +0200 Subject: [PATCH] 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. --- .../Components/Grids/MgGridBase.cs | 244 ++++++++++++++++-- .../Grids/MgGridFilterPanelTagBox.cs | 212 +++++++++++++++ .../Components/MgTagBox.cs | 93 +------ .../docs/MGGRID/MGGRID_ISSUES.md | 30 +++ .../docs/MGGRID/MGGRID_LAYOUT.md | 2 +- .../docs/MGGRID/MGGRID_PARAMETERS.md | 33 ++- .../docs/MGGRID/MGGRID_TOOLBAR.md | 2 +- .../docs/MGGRID/README.md | 10 +- .../wwwroot/css/mg-grid-info-panel.css | 13 + docs/GLOSSARY.md | 8 +- 10 files changed, 523 insertions(+), 124 deletions(-) create mode 100644 AyCode.Blazor.Components/Components/Grids/MgGridFilterPanelTagBox.cs diff --git a/AyCode.Blazor.Components/Components/Grids/MgGridBase.cs b/AyCode.Blazor.Components/Components/Grids/MgGridBase.cs index 48f51df..4d42118 100644 --- a/AyCode.Blazor.Components/Components/Grids/MgGridBase.cs +++ b/AyCode.Blazor.Components/Components/Grids/MgGridBase.cs @@ -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 HasUserLayoutAsync(); /// - /// Applies a per-field quick filter to the grid — delegates to DevExpress SetFieldFilterCriteria, - /// which AND-combines it with the user's filter row (and other fields' quick filters). Passing - /// = null clears this field's filter. See ADR 0004. + /// Applies a filter-panel per-field criteria to the grid — delegates to DevExpress SetFieldFilterCriteria, + /// 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 + /// ). Passing = null clears the + /// field's criteria. See ADR 0004. /// - void SetFieldQuickFilter(string fieldName, CriteriaOperator? criteria); + void SetFilterPanelCriteria(string fieldName, CriteriaOperator? criteria); /// Whether this grid has a quick-filter panel (i.e. FilterPanel is set). bool HasFilterPanel { get; } @@ -112,11 +115,62 @@ public interface IMgGridBase : IGrid void ToggleFilterPanel(); /// - /// The grid's current DevExpress SizeMode (from DxGrid). Quick-filter controls in the - /// FilterPanel (e.g. ) 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 SizeMode (from DxGrid). Filter-panel components (e.g. + /// MgGridFilterPanelTagBox) inherit it as their default when the consumer didn't set one, so the + /// panel matches the grid's density. /// SizeMode? GridSizeMode { get; } + + /// + /// Reads back a field's current criteria (DevExpress GetFieldFilterCriteria) — the counterpart of + /// . Filter-panel controls use it to restore their selection after a + /// persisted layout (which includes the filter criteria) has been applied. See ADR 0004. + /// + CriteriaOperator? GetFilterPanelCriteria(string fieldName); + + /// + /// Registers a filter-panel component (e.g. MgGridFilterPanelTagBox) 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. + /// + void RegisterFilterPanelComponent(IMgGridFilterPanelComponent component); + + /// + /// 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. + /// + void ClearFilterPanel(); + + /// + /// 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 AcObservableCollection + /// filled via Replace) when a restored selection should (re)resolve against the now-available data. + /// + void RefreshFilterPanel(); +} + +/// +/// Contract for filter components hosted in a grid's FilterPanel (e.g. MgGridFilterPanelTagBox; +/// 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 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. +/// +public interface IMgGridFilterPanelComponent +{ + /// Grid column field this component filters (as in DxGridDataColumn.FieldName). + string? FieldName { get; } + + /// + /// Whether is this component's own shape (e.g. the IN(FieldName, ...) an + /// MgGridFilterPanelTagBox 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. + /// + bool OwnsCriteria(CriteriaOperator criteria); + + /// Re-reads this field's criteria from the grid and updates the component's own UI state. + void SyncFromGrid(); } public abstract class MgGridBase : DxGrid, IMgGridBase, IAsyncDisposable @@ -308,15 +362,18 @@ public abstract class MgGridBase - /// Optional quick-filter controls (typically ) rendered below the toolbar (composed into the grid's ToolbarTemplate) + /// Optional filter components (typically MgGridFilterPanelTagBox) rendered below the toolbar (composed into the grid's ToolbarTemplate) /// inside a styled .mg-grid-filter-panel container (the slot wraps the content). Within this grid's - /// 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. /// [Parameter] public RenderFragment? FilterPanel { get; set; } /// Initial visibility of the ; the toolbar toggle flips it at runtime. Default: shown. [Parameter] public bool ShowFilterPanel { get; set; } = true; + /// Text of the filter panel's built-in right-aligned Clear button (see ). + [Parameter] public string FilterPanelClearButtonText { get; set; } = "Clear Filters"; + /// public bool HasFilterPanel => FilterPanel != null; @@ -327,12 +384,132 @@ public abstract class MgGridBase public SizeMode? GridSizeMode => this.SizeMode; + /// + 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> _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; + + /// + public void RegisterFilterPanelComponent(IMgGridFilterPanelComponent component) + { + foreach (var registered in GetRegisteredFilterPanelComponents()) + { + if (ReferenceEquals(registered, component)) + { + ReconcileFilterPanelComponent(component); + return; + } + } + + _filterPanelComponents.Add(new WeakReference(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 GetRegisteredFilterPanelComponents() + { + _filterPanelComponents.RemoveAll(weakReference => !weakReference.TryGetTarget(out _)); + + var live = new List(_filterPanelComponents.Count); + foreach (var weakReference in _filterPanelComponents) + if (weakReference.TryGetTarget(out var component)) + live.Add(component); + + return live; + } + + /// + /// 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. + /// + private void ReconcileFilterPanel() + { + foreach (var component in GetRegisteredFilterPanelComponents()) + ReconcileFilterPanelComponent(component); + } + + private void ReconcileFilterPanelComponent(IMgGridFilterPanelComponent component) + { + if (!IsFilterPanelVisible) ClearOwnedFilterPanelCriteria(component); + + component.SyncFromGrid(); + } + + /// + /// 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 SetFieldFilterCriteria) — a value + /// typed into the filter row (e.g. Contains("EUR")) is visible UI and must survive panel hide / panel clear. + /// + 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); + } + + /// + public void ClearFilterPanel() + { + foreach (var component in GetRegisteredFilterPanelComponents()) + { + ClearOwnedFilterPanelCriteria(component); + component.SyncFromGrid(); + } + + InvokeAsync(StateHasChanged); // refresh the built-in Clear button's enabled state + } + + /// + public void RefreshFilterPanel() + { + ReconcileFilterPanel(); + InvokeAsync(StateHasChanged); + } + + /// + /// 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. + /// + 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? _consumerToolbarTemplate; private RenderFragment? _wrappedToolbarTemplate; @@ -366,13 +543,26 @@ public abstract class MgGridBase