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:
parent
1e518d9a1e
commit
b06d321bfb
|
|
@ -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)
|
||||
{
|
||||
// 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", "mg-grid-filter-panel");
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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><MgGridFilterPanelTagBox FieldName="..." Data="..." /></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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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><MgTagBox FieldName="..." Data="..." /></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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue