Compare commits
6 Commits
4eb0d1566c
...
11d1ae1d17
| Author | SHA1 | Date |
|---|---|---|
|
|
11d1ae1d17 | |
|
|
efd4c67cc0 | |
|
|
d164a99ed9 | |
|
|
b06d321bfb | |
|
|
1e518d9a1e | |
|
|
2d62fad300 |
|
|
@ -9,6 +9,7 @@ using DevExpress.Blazor;
|
|||
using DevExpress.Data.Filtering;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.JSInterop;
|
||||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
|
|
@ -96,11 +97,80 @@ public interface IMgGridBase : IGrid
|
|||
Task<bool> HasUserLayoutAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Applies a per-field quick filter to the grid — delegates to DevExpress <c>SetFieldFilterCriteria</c>,
|
||||
/// which AND-combines it with the user's filter row (and other fields' quick filters). Passing
|
||||
/// <paramref name="criteria"/> = null clears this field's filter. See ADR 0004.
|
||||
/// Applies a filter-panel per-field criteria to the grid — delegates to DevExpress <c>SetFieldFilterCriteria</c>,
|
||||
/// which AND-combines it with the other fields' criteria. NOTE: the per-field criteria slot is SHARED with the
|
||||
/// grid's filter row — the panel only manages criteria its controls recognize as their own (see
|
||||
/// <see cref="IMgGridFilterPanelComponent.OwnsCriteria"/>). Passing <paramref name="criteria"/> = null clears the
|
||||
/// field's criteria. See ADR 0004.
|
||||
/// </summary>
|
||||
void SetFieldQuickFilter(string fieldName, CriteriaOperator? criteria);
|
||||
void SetFilterPanelCriteria(string fieldName, CriteriaOperator? criteria);
|
||||
|
||||
/// <summary>Whether this grid has a quick-filter panel (i.e. <c>FilterPanel</c> is set).</summary>
|
||||
bool HasFilterPanel { get; }
|
||||
|
||||
/// <summary>Whether the quick-filter panel is currently shown (toggled from the toolbar).</summary>
|
||||
bool IsFilterPanelVisible { get; }
|
||||
|
||||
/// <summary>Shows/hides the quick-filter panel. No-op when the grid has no filter panel.</summary>
|
||||
void ToggleFilterPanel();
|
||||
|
||||
/// <summary>
|
||||
/// The grid's current DevExpress <c>SizeMode</c> (from <c>DxGrid</c>). Filter-panel components (e.g.
|
||||
/// <c>MgGridFilterPanelTagBox</c>) inherit it as their default when the consumer didn't set one, so the
|
||||
/// panel matches the grid's density.
|
||||
/// </summary>
|
||||
SizeMode? GridSizeMode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Reads back a field's current criteria (DevExpress <c>GetFieldFilterCriteria</c>) — the counterpart of
|
||||
/// <see cref="SetFilterPanelCriteria"/>. Filter-panel controls use it to restore their selection after a
|
||||
/// persisted layout (which includes the filter criteria) has been applied. See ADR 0004.
|
||||
/// </summary>
|
||||
CriteriaOperator? GetFilterPanelCriteria(string fieldName);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a filter-panel component (e.g. <c>MgGridFilterPanelTagBox</c>) so the grid can reconcile it
|
||||
/// whenever the criteria may change under it (layout restore — incl. a server-roamed layout —, user-layout
|
||||
/// load, layout reset, panel show/hide). Held weakly — no unregistration needed; the component is reconciled
|
||||
/// immediately on registration.
|
||||
/// </summary>
|
||||
void RegisterFilterPanelComponent(IMgGridFilterPanelComponent component);
|
||||
|
||||
/// <summary>
|
||||
/// Clears every registered filter-panel component's OWN criteria (the grid's filter row is untouched) and
|
||||
/// empties the components. Wired to the panel's built-in right-aligned Clear button; callable from consumers.
|
||||
/// </summary>
|
||||
void ClearFilterPanel();
|
||||
|
||||
/// <summary>
|
||||
/// Re-runs the filter-panel reconcile on demand: every registered component re-reads its field's criteria
|
||||
/// and re-renders. Call after a late or in-place lookup-data load (e.g. an <c>AcObservableCollection</c>
|
||||
/// filled via <c>Replace</c>) when a restored selection should (re)resolve against the now-available data.
|
||||
/// </summary>
|
||||
void RefreshFilterPanel();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contract for filter components hosted in a grid's <c>FilterPanel</c> (e.g. <c>MgGridFilterPanelTagBox</c>;
|
||||
/// future dropdown/textbox/checkbox filter components implement the same contract over their own Mg* seams).
|
||||
/// Registered components are held weakly by the grid, which calls <see cref="SyncFromGrid"/> whenever the grid's
|
||||
/// criteria may have changed under them, so the panel UI always shows exactly what is filtering the grid
|
||||
/// (no invisible panel filter). See ADR 0004.
|
||||
/// </summary>
|
||||
public interface IMgGridFilterPanelComponent
|
||||
{
|
||||
/// <summary>Grid column field this component filters (as in <c>DxGridDataColumn.FieldName</c>).</summary>
|
||||
string? FieldName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="criteria"/> is this component's own shape (e.g. the <c>IN(FieldName, ...)</c> an
|
||||
/// <c>MgGridFilterPanelTagBox</c> writes). The per-field criteria slot is SHARED with the grid's filter row —
|
||||
/// the panel may only clear/manage criteria its component owns; a value typed into the filter row must survive.
|
||||
/// </summary>
|
||||
bool OwnsCriteria(CriteriaOperator criteria);
|
||||
|
||||
/// <summary>Re-reads this field's criteria from the grid and updates the component's own UI state.</summary>
|
||||
void SyncFromGrid();
|
||||
}
|
||||
|
||||
public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient> : DxGrid, IMgGridBase, IAsyncDisposable
|
||||
|
|
@ -251,7 +321,6 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
|||
// Body
|
||||
contentBuilder.OpenElement(12, "div");
|
||||
contentBuilder.AddAttribute(13, "class", "mg-fullscreen-body");
|
||||
if (FilterPanel != null) contentBuilder.AddContent(14, FilterPanel);
|
||||
base.BuildRenderTree(contentBuilder);
|
||||
contentBuilder.CloseElement(); // body div
|
||||
|
||||
|
|
@ -263,7 +332,6 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
|||
contentBuilder.OpenElement(0, "div");
|
||||
contentBuilder.SetKey(_gridRenderKey);
|
||||
contentBuilder.AddAttribute(1, "style", "display: contents;");
|
||||
if (FilterPanel != null) contentBuilder.AddContent(2, FilterPanel);
|
||||
base.BuildRenderTree(contentBuilder);
|
||||
contentBuilder.CloseElement();
|
||||
}
|
||||
|
|
@ -294,12 +362,214 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
|||
[Parameter] public string Caption { get; set; } = typeof(TDataItem).Name;
|
||||
|
||||
/// <summary>
|
||||
/// Optional quick-filter panel rendered above the grid (typically a <c>GridFilterPanel</c> hosting
|
||||
/// <see cref="MgTagBox{TData,TValue}"/> controls). Rendered within this grid's <see cref="IMgGridBase"/>
|
||||
/// cascade, so the controls can reach this grid and apply per-field filters. See ADR 0004.
|
||||
/// Optional filter components (typically <c>MgGridFilterPanelTagBox</c>) rendered below the toolbar (composed into the grid's ToolbarTemplate)
|
||||
/// inside a styled <c>.mg-grid-filter-panel</c> container (the slot wraps the content). Within this grid's <see cref="IMgGridBase"/>
|
||||
/// cascade, so the components can reach this grid and apply per-field filters. See ADR 0004.
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? FilterPanel { get; set; }
|
||||
|
||||
/// <summary>Initial visibility of the <see cref="FilterPanel"/>; the toolbar toggle flips it at runtime. Default: shown.</summary>
|
||||
[Parameter] public bool ShowFilterPanel { get; set; } = true;
|
||||
|
||||
/// <summary>Text of the filter panel's built-in right-aligned Clear button (see <see cref="IMgGridBase.ClearFilterPanel"/>).</summary>
|
||||
[Parameter] public string FilterPanelClearButtonText { get; set; } = "Clear Filters";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasFilterPanel => FilterPanel != null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsFilterPanelVisible { get; private set; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ToggleFilterPanel()
|
||||
{
|
||||
IsFilterPanelVisible = !IsFilterPanelVisible;
|
||||
ReconcileFilterPanel(); // hiding clears the panel's OWN filters — nothing may filter invisibly (ADR 0004)
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SizeMode? GridSizeMode => this.SizeMode;
|
||||
|
||||
/// <inheritdoc />
|
||||
public CriteriaOperator? GetFilterPanelCriteria(string fieldName) => GetFieldFilterCriteria(fieldName);
|
||||
|
||||
// Registered filter-panel components (FilterPanel content). Weak references: the DevExpress editor seams have
|
||||
// no public disposal seam to unregister from, and the components live inside this grid's own toolbar template
|
||||
// anyway — dead entries are pruned on each use.
|
||||
private readonly List<WeakReference<IMgGridFilterPanelComponent>> _filterPanelComponents = [];
|
||||
|
||||
// Set when a restored layout may have brought filter criteria with it (GridPersistentLayout.FilterCriteria);
|
||||
// processed in OnAfterRenderAsync, AFTER the grid has applied the layout.
|
||||
private bool _pendingFilterPanelReconcile;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RegisterFilterPanelComponent(IMgGridFilterPanelComponent component)
|
||||
{
|
||||
foreach (var registered in GetRegisteredFilterPanelComponents())
|
||||
{
|
||||
if (ReferenceEquals(registered, component))
|
||||
{
|
||||
ReconcileFilterPanelComponent(component);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_filterPanelComponents.Add(new WeakReference<IMgGridFilterPanelComponent>(component));
|
||||
|
||||
// Immediate reconcile: a component registering AFTER the layout restore (typical — the panel renders inside
|
||||
// the toolbar template) picks up the restored criteria right away; before it, the pending reconcile covers it.
|
||||
ReconcileFilterPanelComponent(component);
|
||||
}
|
||||
|
||||
private List<IMgGridFilterPanelComponent> GetRegisteredFilterPanelComponents()
|
||||
{
|
||||
_filterPanelComponents.RemoveAll(weakReference => !weakReference.TryGetTarget(out _));
|
||||
|
||||
var live = new List<IMgGridFilterPanelComponent>(_filterPanelComponents.Count);
|
||||
foreach (var weakReference in _filterPanelComponents)
|
||||
if (weakReference.TryGetTarget(out var component))
|
||||
live.Add(component);
|
||||
|
||||
return live;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brings every registered filter-panel component in line with the grid's criteria. Visible panel: the
|
||||
/// components re-read and display their field's criteria (e.g. after a persisted/roamed layout restored it).
|
||||
/// Hidden panel: the components' OWN criteria are CLEARED — a panel filter must never filter invisibly.
|
||||
/// Known edge (layout restored where the grid has no FilterPanel at all): MGGRID_ISSUES.md ACBLAZOR-GRID-I-D4T7.
|
||||
/// </summary>
|
||||
private void ReconcileFilterPanel()
|
||||
{
|
||||
foreach (var component in GetRegisteredFilterPanelComponents())
|
||||
ReconcileFilterPanelComponent(component);
|
||||
}
|
||||
|
||||
private void ReconcileFilterPanelComponent(IMgGridFilterPanelComponent component)
|
||||
{
|
||||
if (!IsFilterPanelVisible) ClearOwnedFilterPanelCriteria(component);
|
||||
|
||||
component.SyncFromGrid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the component's field criteria ONLY when the component recognizes it as its own. The per-field
|
||||
/// criteria slot is SHARED with the grid's filter row (both go through <c>SetFieldFilterCriteria</c>) — a value
|
||||
/// typed into the filter row (e.g. Contains("EUR")) is visible UI and must survive panel hide / panel clear.
|
||||
/// </summary>
|
||||
private void ClearOwnedFilterPanelCriteria(IMgGridFilterPanelComponent component)
|
||||
{
|
||||
var fieldName = component.FieldName;
|
||||
if (string.IsNullOrWhiteSpace(fieldName)) return;
|
||||
|
||||
// NOTE: CriteriaOperator overloads == / != (they BUILD criteria) — null checks must be pattern-based.
|
||||
if (GetFieldFilterCriteria(fieldName) is { } criteria && component.OwnsCriteria(criteria))
|
||||
SetFieldFilterCriteria(fieldName, null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearFilterPanel()
|
||||
{
|
||||
foreach (var component in GetRegisteredFilterPanelComponents())
|
||||
{
|
||||
ClearOwnedFilterPanelCriteria(component);
|
||||
component.SyncFromGrid();
|
||||
}
|
||||
|
||||
InvokeAsync(StateHasChanged); // refresh the built-in Clear button's enabled state
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RefreshFilterPanel()
|
||||
{
|
||||
ReconcileFilterPanel();
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether any registered filter-panel component currently OWNS an active criteria — i.e. the panel is actually
|
||||
/// filtering. Drives the built-in Clear button's enabled state. Diagnostic by design: if the button is enabled
|
||||
/// while every panel component looks empty, a panel criteria is active without UI — the invisible-filter bug
|
||||
/// signature, made visible on purpose.
|
||||
/// </summary>
|
||||
public bool HasActiveFilterPanelCriteria
|
||||
{
|
||||
get
|
||||
{
|
||||
foreach (var component in GetRegisteredFilterPanelComponents())
|
||||
{
|
||||
var fieldName = component.FieldName;
|
||||
if (string.IsNullOrWhiteSpace(fieldName)) continue;
|
||||
|
||||
if (GetFieldFilterCriteria(fieldName) is { } criteria && component.OwnsCriteria(criteria))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private RenderFragment<GridToolbarTemplateContext>? _consumerToolbarTemplate;
|
||||
private RenderFragment<GridToolbarTemplateContext>? _wrappedToolbarTemplate;
|
||||
|
||||
/// <summary>
|
||||
/// Renders the <see cref="FilterPanel"/> BELOW the toolbar by composing it into the grid's ToolbarTemplate:
|
||||
/// the consumer's toolbar first, then the styled <c>.mg-grid-filter-panel</c> container. Re-wraps whenever the
|
||||
/// consumer supplies a new template instance (Blazor re-applies markup parameters on every SetParametersAsync);
|
||||
/// no-op when the incoming template is already our wrapper. <see cref="IsFilterPanelVisible"/> is read at render
|
||||
/// time inside the fragment, so the toolbar toggle works without re-wrapping.
|
||||
/// COMPILE-VERIFY: ToolbarTemplate is RenderFragment<GridToolbarTemplateContext> on this DevExpress version.
|
||||
/// </summary>
|
||||
private void WrapToolbarTemplateWithFilterPanel()
|
||||
{
|
||||
if (FilterPanel == null) return;
|
||||
if (ReferenceEquals(ToolbarTemplate, _wrappedToolbarTemplate)) return;
|
||||
|
||||
_consumerToolbarTemplate = ToolbarTemplate;
|
||||
|
||||
_wrappedToolbarTemplate = context => builder =>
|
||||
{
|
||||
// Wrap toolbar + filter panel in a vertical (column) container so the panel renders BELOW the toolbar,
|
||||
// not beside it — the grid's toolbar region lays its children out horizontally otherwise.
|
||||
builder.OpenElement(0, "div");
|
||||
builder.AddAttribute(1, "class", "mg-grid-toolbar-with-filter");
|
||||
|
||||
// Toolbar row carries the toolbar-container's native padding tokens (the container's own padding is
|
||||
// zeroed by CSS when it hosts this wrapper) so the toolbar keeps its exact native look while the
|
||||
// filter band below can span the grid's full width. See .mg-grid-toolbar-row in mg-grid-info-panel.css.
|
||||
builder.OpenElement(2, "div");
|
||||
builder.AddAttribute(3, "class", "mg-grid-toolbar-row");
|
||||
if (_consumerToolbarTemplate != null) builder.AddContent(4, _consumerToolbarTemplate(context));
|
||||
builder.CloseElement();
|
||||
|
||||
// Always rendered, CSS-hidden when toggled off: the controls must EXIST (and stay registered) even
|
||||
// while hidden — that is how the grid knows WHICH fields are panel-owned when it must clear them
|
||||
// (hidden panel => no active panel filter; see ReconcileFilterPanelControl).
|
||||
builder.OpenElement(5, "div");
|
||||
builder.AddAttribute(6, "class", IsFilterPanelVisible ? "mg-grid-filter-panel" : "mg-grid-filter-panel mg-hidden");
|
||||
builder.AddContent(7, FilterPanel);
|
||||
|
||||
// Built-in right-aligned Clear button — clears the panel's OWN filters only (filter row untouched),
|
||||
// so users don't have to hide/show the panel to reset it.
|
||||
builder.OpenComponent<DxButton>(8);
|
||||
builder.AddAttribute(9, nameof(DxButton.Text), FilterPanelClearButtonText);
|
||||
builder.AddAttribute(10, nameof(DxButton.RenderStyle), ButtonRenderStyle.Secondary);
|
||||
builder.AddAttribute(11, nameof(DxButton.RenderStyleMode), ButtonRenderStyleMode.Outline);
|
||||
builder.AddAttribute(12, nameof(DxButton.SizeMode), this.SizeMode);
|
||||
builder.AddAttribute(13, nameof(DxButton.CssClass), "mg-grid-filter-panel-clear");
|
||||
builder.AddAttribute(14, nameof(DxButton.Enabled), HasActiveFilterPanelCriteria);
|
||||
builder.AddAttribute(15, nameof(DxButton.Click), EventCallback.Factory.Create<MouseEventArgs>(this, _ => ClearFilterPanel()));
|
||||
builder.CloseComponent();
|
||||
|
||||
builder.CloseElement();
|
||||
|
||||
builder.CloseElement();
|
||||
};
|
||||
|
||||
ToolbarTemplate = _wrappedToolbarTemplate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Name for auto-saving/loading grid layout. If not set, defaults to "Grid{TDataItem.Name}"
|
||||
/// </summary>
|
||||
|
|
@ -308,19 +578,25 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
|||
public bool IsMasterGrid => ParentDataItem == null;
|
||||
protected PropertyInfo? KeyFieldPropertyInfoToParent;
|
||||
|
||||
private string? _filterText = null;
|
||||
private string? _customFilterCriteria = null;
|
||||
|
||||
/// <summary>
|
||||
/// Consumer-supplied base filter criteria (a DevExpress CriteriaOperator string) — propagated to the data source
|
||||
/// (appended as the LAST positional GetAll parameter) and triggers a reload; the server parses + whitelists it.
|
||||
/// Renamed FilterText → CustomFilterText → CustomFilterCriteria (2026-07-16) as the semantics settled to a
|
||||
/// criteria string. Under the ADR-0004 unified model it is AND-merged with the grid's automatic FilterCriteria.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? FilterText
|
||||
public string? CustomFilterCriteria
|
||||
{
|
||||
get => _filterText;
|
||||
get => _customFilterCriteria;
|
||||
set
|
||||
{
|
||||
_filterText = value;
|
||||
_customFilterCriteria = value;
|
||||
|
||||
if (_dataSource != null && _dataSource.FilterText != value)
|
||||
if (_dataSource != null && _dataSource.CustomFilterCriteria != value)
|
||||
{
|
||||
_dataSource.FilterText = value;
|
||||
_dataSource.CustomFilterCriteria = value;
|
||||
ReloadDataSourceAsync().Forget(Logger);
|
||||
}
|
||||
}
|
||||
|
|
@ -406,6 +682,8 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
|||
if (Logger == null)
|
||||
throw new NullReferenceException($"[{GetType().Name}] Logger == null");
|
||||
|
||||
IsFilterPanelVisible = ShowFilterPanel;
|
||||
|
||||
if (SignalRClient == null)
|
||||
{
|
||||
Logger.Error($"[{GetType().Name}] SignalRClient == null");
|
||||
|
|
@ -415,7 +693,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
|||
var crudTags = new SignalRCrudTags(GetAllMessageTag, GetItemMessageTag, AddMessageTag, UpdateMessageTag, RemoveMessageTag);
|
||||
|
||||
_dataSource = (TSignalRDataSource)Activator.CreateInstance(typeof(TSignalRDataSource), SignalRClient, crudTags, Logger, ContextIds)!;
|
||||
_dataSource.FilterText = FilterText;
|
||||
_dataSource.CustomFilterCriteria = CustomFilterCriteria;
|
||||
|
||||
SetGridData(_dataSource.GetReferenceInnerList());
|
||||
|
||||
|
|
@ -472,6 +750,14 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
|||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
// Runs before the data-source block (which may early-return): the filter-panel state is independent of
|
||||
// the data being loaded, and the restored layout has been applied by this point.
|
||||
if (_pendingFilterPanelReconcile)
|
||||
{
|
||||
_pendingFilterPanelReconcile = false;
|
||||
ReconcileFilterPanel();
|
||||
}
|
||||
|
||||
if (firstRender)
|
||||
{
|
||||
if (_dataSource == null) return;
|
||||
|
|
@ -733,6 +1019,8 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
|||
{
|
||||
await base.SetParametersAsyncCore(parameters);
|
||||
|
||||
WrapToolbarTemplateWithFilterPanel();
|
||||
|
||||
if (!IsFirstInitializeParameterCore)
|
||||
{
|
||||
//if (typeof(TDataItem) is IId<TId> || typeof(TDataItem) is IId<TId>)
|
||||
|
|
@ -841,6 +1129,11 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
|||
_defaultLayoutJson ??= JsonSerializer.Serialize(SaveLayout());
|
||||
|
||||
e.Layout = await LoadLayoutFromLocalStorageAsync(AutomaticLayoutStorageKey);
|
||||
|
||||
// The restored layout may carry filter-panel criteria (GridPersistentLayout.FilterCriteria). Reconcile
|
||||
// AFTER the grid has applied it (next OnAfterRenderAsync): visible panel => controls re-select; hidden
|
||||
// panel => the panel's own criteria is cleared. See ReconcileFilterPanel / ADR 0004.
|
||||
if (e.Layout != null) _pendingFilterPanelReconcile = true;
|
||||
}
|
||||
|
||||
private async Task Grid_LayoutAutoSaving(GridPersistentLayoutEventArgs e)
|
||||
|
|
@ -948,6 +1241,10 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
|||
if (layout != null)
|
||||
{
|
||||
LoadLayout(layout);
|
||||
|
||||
// The loaded (possibly server-roamed) layout may carry filter-panel criteria — reconcile the panel
|
||||
// controls so what filters the grid is what the panel shows (ADR 0004).
|
||||
ReconcileFilterPanel();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -963,6 +1260,9 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
|||
if (defaultLayout != null)
|
||||
LoadLayout(defaultLayout);
|
||||
}
|
||||
|
||||
// The default layout has no filter-panel criteria — the panel controls must empty accordingly.
|
||||
ReconcileFilterPanel();
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1022,7 +1322,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
|
|||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetFieldQuickFilter(string fieldName, CriteriaOperator? criteria)
|
||||
public void SetFilterPanelCriteria(string fieldName, CriteriaOperator? criteria)
|
||||
=> SetFieldFilterCriteria(fieldName, criteria);
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
|
||||
namespace AyCode.Blazor.Components.Components.Grids;
|
||||
|
||||
/// <summary>
|
||||
/// Base for a grid quick-filter panel — a styled container placed in a grid's <c>FilterPanel</c> slot,
|
||||
/// hosting <see cref="MgTagBox{TData,TValue}"/> (or other) per-field quick-filter controls above the grid.
|
||||
/// <para>
|
||||
/// Framework base. Projects derive a (typically empty) <c>GridFilterPanel</c> per the Mg* seam policy
|
||||
/// (cf. <c>MgGridBase</c> → project grid). The grid reference reaches the child controls via the grid's own
|
||||
/// <see cref="IMgGridBase"/> cascade (<c>MgGridBase</c> provides it) — this panel is layout only, no wiring.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class MgGridFilterPanelBase : ComponentBase
|
||||
{
|
||||
/// <summary>The quick-filter controls (e.g. <see cref="MgTagBox{TData,TValue}"/>) to render in the panel.</summary>
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>Extra CSS classes appended to the panel container.</summary>
|
||||
[Parameter] public string? CssClass { get; set; }
|
||||
|
||||
protected override void BuildRenderTree(RenderTreeBuilder builder)
|
||||
{
|
||||
builder.OpenElement(0, "div");
|
||||
builder.AddAttribute(1, "class", string.IsNullOrWhiteSpace(CssClass)
|
||||
? "mg-grid-filter-panel"
|
||||
: $"mg-grid-filter-panel {CssClass}");
|
||||
builder.AddContent(2, ChildContent);
|
||||
builder.CloseElement();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
using DevExpress.Blazor;
|
||||
using DevExpress.Data.Filtering;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AyCode.Blazor.Components.Components.Grids;
|
||||
|
||||
/// <summary>
|
||||
/// Filter-panel tag box for MgGrid — <see cref="MgTagBox{TData,TValue}"/> (the universal, behavior-free
|
||||
/// <c>DxTagBox</c> seam) plus the grid filter-panel wiring (<see cref="IMgGridFilterPanelComponent"/>).
|
||||
/// <para>
|
||||
/// Place inside a grid's <c>FilterPanel</c>. Changing the selection applies a per-field
|
||||
/// <c>IN(FieldName, selected...)</c> criteria to the grid via <see cref="IMgGridBase.SetFilterPanelCriteria"/>;
|
||||
/// DevExpress AND-combines it with the user's filter row (it uses <c>SetFieldFilterCriteria</c>, NOT
|
||||
/// <c>SetFilterCriteria</c>) — see ADR 0004.
|
||||
/// </para>
|
||||
/// The selection is managed internally, so the consumer writes just
|
||||
/// <c><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();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@
|
|||
</Items>
|
||||
</DxToolbarItem>
|
||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Reload data")" BeginGroup="true" Click="ReloadData_Click" IconCssClass="grid-refresh" Enabled="@(!IsSyncing && !_isReloadInProgress && !IsEditing)" />
|
||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : FilterPanelButtonText)" Click="FilterPanel_Click" IconCssClass="grid-filter" Visible="@Grid.HasFilterPanel" Enabled="@(!IsEditing)" />
|
||||
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : FullscreenButtonText)" Click="Fullscreen_Click" IconCssClass="@FullscreenIconCssClass" Enabled="@(!IsEditing)" />
|
||||
@ToolbarItemsExtended
|
||||
}
|
||||
|
|
@ -82,6 +83,16 @@
|
|||
/// </summary>
|
||||
private string FullscreenIconCssClass => IsFullscreenMode ? "grid-fullscreen-exit" : "grid-fullscreen";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the quick-filter panel is currently shown
|
||||
/// </summary>
|
||||
private bool IsFilterPanelVisible => Grid?.IsFilterPanelVisible ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Button text for the filter-panel toggle
|
||||
/// </summary>
|
||||
private string FilterPanelButtonText => IsFilterPanelVisible ? "Hide Filters" : "Show Filters";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_hasUserLayout = await Grid.HasUserLayoutAsync();
|
||||
|
|
@ -145,6 +156,11 @@
|
|||
Grid.ToggleFullscreen();
|
||||
}
|
||||
|
||||
void FilterPanel_Click()
|
||||
{
|
||||
Grid.ToggleFilterPanel();
|
||||
}
|
||||
|
||||
async Task ExportXlsxItem_Click()
|
||||
{
|
||||
await Grid.ExportToXlsxAsync(ExportFileName);
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ Core grid system built on DevExpress `DxGrid`. For the full technical reference
|
|||
|
||||
## Key Files
|
||||
|
||||
- **`MgGridBase.cs`** — `MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient>`, the main abstract grid component. Extends `DxGrid` with SignalR CRUD, layout persistence, master-detail hierarchy, edit state tracking, fullscreen toggle.
|
||||
- **`MgGridBase.cs`** — `MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient>`, the main abstract grid component. Extends `DxGrid` with SignalR CRUD, layout persistence, master-detail hierarchy, edit state tracking, fullscreen toggle, and a quick-filter `FilterPanel` slot (renders `MgTagBox` controls below the toolbar in a styled `.mg-grid-filter-panel` container; toolbar Show/Hide toggle).
|
||||
- **`MgGridWithInfoPanel.razor`** — `DxSplitter` wrapper: grid (left) + InfoPanel (right), fullscreen overlay, splitter size persistence.
|
||||
- **`MgGridToolbarBase.cs`** — Extends `DxToolbar` with `Grid`, `RefreshClick`, and `ShowOnlyIcon` parameters.
|
||||
- **`MgGridToolbarTemplate.razor`** — Full toolbar template: New/Edit/Delete/Save/Cancel, row navigation, layout menu (Load/Save/Reset), column chooser, export, reload, fullscreen. Extensible via `ToolbarItemsExtended`.
|
||||
- **`MgGridToolbarTemplate.razor`** — Full toolbar template: New/Edit/Delete/Save/Cancel, row navigation, layout menu (Load/Save/Reset), column chooser, export, reload, filter-panel toggle (visible only when the grid has a `FilterPanel`), fullscreen. Extensible via `ToolbarItemsExtended`.
|
||||
- **`MgGridDataColumn.cs`** — Extends `DxGridDataColumn` with InfoPanel parameters (`ShowInInfoPanel`, `InfoPanelOrder`, `InfoPanelDisplayFormat`) and `UrlLink` template with `{Property}` placeholder substitution via compiled accessors.
|
||||
- **`MgGridInfoPanel.razor`** / **`.razor.cs`** — `MgGridInfoPanel` implementing `IInfoPanelBase`. Responsive column layout (1-4 columns with breakpoints), edit/view mode with typed editors, template system, sticky positioning via JS interop.
|
||||
- **`MgGridSignalRDataSource.cs`** — `GridCustomDataSource` wrapping `AcSignalRDataSource`. Local cache for seen filter criteria, background refresh.
|
||||
|
|
|
|||
|
|
@ -1,77 +1,15 @@
|
|||
using AyCode.Blazor.Components.Components.Grids;
|
||||
using DevExpress.Blazor;
|
||||
using DevExpress.Data.Filtering;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AyCode.Blazor.Components.Components;
|
||||
|
||||
/// <summary>
|
||||
/// <c>DxTagBox</c> seam (per the Mg* "derive every DevExpress component" policy) with an added grid
|
||||
/// quick-filter capability.
|
||||
/// <para>
|
||||
/// When placed inside a grid's <c>FilterPanel</c>, changing the selection applies a per-field
|
||||
/// <c>IN(FieldName, selected...)</c> filter to the grid via <see cref="IMgGridBase.SetFieldQuickFilter"/>.
|
||||
/// DevExpress AND-combines it with the user's filter row (it uses <c>SetFieldFilterCriteria</c>, NOT
|
||||
/// <c>SetFilterCriteria</c>) — see ADR 0004.
|
||||
/// </para>
|
||||
/// The selection is managed internally, so the consumer writes just
|
||||
/// <c><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; }
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Components
|
||||
|
||||
DevExpress component wrappers and grid infrastructure for the AyCode Blazor component library. Each `Ac*` class extends a DevExpress Blazor control to allow project-wide customization from a single point.
|
||||
DevExpress component wrappers and grid infrastructure for the AyCode Blazor component library. Each wrapper class extends a DevExpress Blazor control to allow project-wide customization from a single point (`Mg*` = current prefix; `Ac*` = legacy prefix, kept as-is).
|
||||
|
||||
## Key Files
|
||||
|
||||
|
|
@ -13,6 +13,7 @@ DevExpress component wrappers and grid infrastructure for the AyCode Blazor comp
|
|||
- **`AcMaskedInput.cs`** -- Generic wrapper for `DxMaskedInput<T>`.
|
||||
- **`AcMemo.cs`** -- Extends `DxMemo`.
|
||||
- **`AcSpinEdit.cs`** -- Generic wrapper for `DxSpinEdit<T>`.
|
||||
- **`MgTagBox.cs`** -- Generic wrapper for `DxTagBox<TData, TValue>` with grid quick-filter support: a `FieldName` parameter + the grid cascade let a selection apply a per-field `IN` filter via `IMgGridBase.SetFieldQuickFilter` (see `Grids/` and `docs/MGGRID/MGGRID_PARAMETERS.md` → Quick-Filter Panel).
|
||||
- **`MgComponentsHelper.cs`** -- Placeholder helper class (currently empty).
|
||||
|
||||
## Subfolders
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
├── Validate Logger, SignalRClient (throw if null)
|
||||
├── Create SignalRCrudTags from message tag parameters
|
||||
├── Create TSignalRDataSource via Activator.CreateInstance(SignalRClient, crudTags, ContextIds)
|
||||
├── Set DataSource.FilterText
|
||||
├── Set DataSource.CustomFilterCriteria
|
||||
├── Bind grid Data to data source inner list
|
||||
└── Subscribe to: OnDataSourceLoaded, OnDataSourceItemChanged, OnSyncingStateChanged
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,37 @@ so both the master and the built-in InfoPanel toolbar respect a single consumer
|
|||
### Related TODO
|
||||
None yet — becomes a TODO when scheduled.
|
||||
|
||||
## ACBLAZOR-GRID-I-D4T7: Roamed layout with quick-filter criteria cannot be cleared where the grid has no FilterPanel
|
||||
|
||||
**Severity:** Minor (rare version-/markup-skew scenario) · **Status:** Open · **Area:** `Components/Grids/MgGridBase.cs` (filter-panel reconcile) + layout roaming (`MGGRID_LAYOUT.md`)
|
||||
|
||||
### Description
|
||||
Filter-panel criteria persist inside the grid layout (`GridPersistentLayout.FilterCriteria`) — including the
|
||||
server-roamed UserSave tier. On restore the grid **reconciles** the registered `FilterPanel` controls: visible
|
||||
panel → the controls re-select from the criteria; hidden panel → the controls' own criteria is cleared (a panel
|
||||
filter must never filter invisibly). BUT the reconcile identifies panel-owned fields **from the registered
|
||||
controls**. If a layout saved by a grid WITH a `FilterPanel` is loaded where the same grid has NO panel at all
|
||||
(older client build, or a page variant without the panel), no controls register → the panel criteria stays
|
||||
applied invisibly (shape alone is not enough to strip it safely — an `IN` can also come from legitimate
|
||||
header-filter use).
|
||||
|
||||
### Root cause
|
||||
Field ownership ("this criteria belongs to a filter-panel component") exists only at runtime via component
|
||||
registration + shape recognition (`IMgGridFilterPanelComponent.OwnsCriteria`); the persisted layout does not
|
||||
record which fields were panel-owned.
|
||||
|
||||
### Known workaround
|
||||
Reset layout (toolbar) on the affected client, or re-save the layout from a client that has the panel.
|
||||
|
||||
### Proposed fix
|
||||
Wrap the persisted layout in an MgGrid envelope (`MgGridLayoutSave { Version, GridLayoutJson,
|
||||
FilterPanelLayoutJson }`) so panel state never rides inside the DevExpress layout: an entry no component claims
|
||||
simply never reaches the grid — this issue is then closed **by construction**, no clearing logic needed. Design in
|
||||
ADR 0004 (AyCode.Core repo) → "Planned v2".
|
||||
|
||||
### Related TODO
|
||||
`MGGRID_TODO.md#acblazor-grid-t-w8q3` (the envelope + pull-model restore — closes this issue).
|
||||
|
||||
## Issue entry template
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ using the framework tags defined in `AcSignalRTags` (AyCode.Services):
|
|||
|
||||
## Persisted State
|
||||
|
||||
The layout (`GridPersistentLayout`) includes: column order, column widths, sort descriptors, group descriptors, filter row values, page size — serialized as JSON via `System.Text.Json`.
|
||||
The layout (`GridPersistentLayout`) includes: column order, column widths, sort descriptors, group descriptors, filter row values, filter criteria (`FilterCriteria` — including the filter panel's `IN` criteria; on restore the grid reconciles the `FilterPanel` controls so restored panel filters re-select, see `MGGRID_PARAMETERS.md` → Filter Panel → Persistence & visibility), page size — serialized as JSON via `System.Text.Json`.
|
||||
|
||||
## User Identification
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -15,6 +15,7 @@ The standard toolbar rendered inside grid's `ToolbarTemplate`. Provides all stan
|
|||
| **Navigation** | Prev Row, Next Row | When NOT editing |
|
||||
| **Layout** | Column Chooser, Layout (Load/Save/Reset) | When `OnlyGridEditTools=false` |
|
||||
| **Actions** | Export (CSV/XLSX/XLS/PDF), Reload Data, Fullscreen | When `OnlyGridEditTools=false` |
|
||||
| **Filter panel** | Show Filters / Hide Filters (toggles the `FilterPanel`; hiding clears the panel's own filters — the panel also has a built-in Clear button) | When `OnlyGridEditTools=false` AND `Grid.HasFilterPanel` |
|
||||
|
||||
### Parameters
|
||||
|
||||
|
|
@ -37,3 +38,4 @@ The standard toolbar rendered inside grid's `ToolbarTemplate`. Provides all stan
|
|||
| `IsSyncing` | `Grid.IsSyncing` |
|
||||
| `HasFocusedRow` | `Grid.GetFocusedRowIndex() >= 0` |
|
||||
| `IsFullscreenMode` | `Grid.IsFullscreen` |
|
||||
| `IsFilterPanelVisible` | `Grid.IsFilterPanelVisible` |
|
||||
|
|
|
|||
|
|
@ -14,12 +14,13 @@
|
|||
- **InfoPanel integration** — side panel shows focused row details, supports edit mode
|
||||
- **Fullscreen mode** — standalone overlay or via `MgGridWithInfoPanel` wrapper
|
||||
- **Change tracking** — client-side tracking with server sync via `SaveChangesAsync`
|
||||
- **Filter panel** — `FilterPanel` slot below the toolbar hosting `MgGridFilterPanelTagBox` components (`IMgGridFilterPanelComponent`); per-field criteria AND-combined by DevExpress with the user's filter row; toolbar Show/Hide toggle + built-in right-aligned Clear button (see `MGGRID_PARAMETERS.md` → Filter Panel; design: AyCode.Core repo `docs/adr/0004-signalr-datasource-server-side-filtering.md`)
|
||||
|
||||
## Detailed Documentation
|
||||
|
||||
| File | Topics |
|
||||
|---|---|
|
||||
| `MGGRID_PARAMETERS.md` | Component parameters (required, CRUD tags, data & context, display), event callbacks, default grid settings |
|
||||
| `MGGRID_PARAMETERS.md` | Component parameters (required, CRUD tags, data & context, display), quick-filter panel (`FilterPanel` slot + `MgTagBox`), event callbacks, default grid settings |
|
||||
| `MGGRID_CRUD.md` | Lifecycle, CRUD operations, ID generation, edit flow, disposal |
|
||||
| `MGGRID_LAYOUT.md` | Layout persistence (storage keys, three tiers, operations, persisted state) |
|
||||
| `MGGRID_DETAIL.md` | Master-detail hierarchy |
|
||||
|
|
@ -96,6 +97,15 @@ The public contract exposed to companion components (toolbar, InfoPanel, wrapper
|
|||
| `IsFullscreen` | `bool` | Current fullscreen state |
|
||||
| `AutomaticLayoutStorageKey` | `string` | Current auto-save storage key |
|
||||
| `ToggleFullscreen()` | `void` | Toggle fullscreen mode |
|
||||
| `SetFilterPanelCriteria(fieldName, criteria)` | `void` | Apply/clear a filter-panel per-field criteria (delegates to DevExpress `SetFieldFilterCriteria`; `null` clears the field; the per-field slot is shared with the filter row) |
|
||||
| `HasFilterPanel` | `bool` | Whether the grid has a `FilterPanel` (drives toolbar-toggle visibility) |
|
||||
| `IsFilterPanelVisible` | `bool` | Current quick-filter panel visibility |
|
||||
| `ToggleFilterPanel()` | `void` | Show/hide the quick-filter panel |
|
||||
| `GridSizeMode` | `SizeMode?` | The grid's DevExpress `SizeMode`; filter-panel components inherit it as their default when the consumer sets none |
|
||||
| `GetFilterPanelCriteria(fieldName)` | `CriteriaOperator?` | Reads back a field's criteria (counterpart of `SetFilterPanelCriteria`; used to restore components after a persisted layout applied) |
|
||||
| `RegisterFilterPanelComponent(component)` | `void` | Registers an `IMgGridFilterPanelComponent` for criteria reconcile (weakly held, synced immediately; hidden panel ⇒ the components' OWN criteria are cleared) |
|
||||
| `ClearFilterPanel()` | `void` | Clears every registered component's OWN criteria + empties the components (filter row untouched); wired to the panel's built-in Clear button (enabled only while a panel-owned criteria is active) |
|
||||
| `RefreshFilterPanel()` | `void` | Re-runs the filter-panel reconcile on demand — call after a late/in-place lookup-data load (e.g. `AcObservableCollection.Replace`) so restored selections resolve against the arrived data |
|
||||
| `SaveUserLayoutAsync()` | `Task` | Save layout manually |
|
||||
| `LoadUserLayoutAsync()` | `Task` | Load manually saved layout |
|
||||
| `ResetLayoutAsync()` | `Task` | Reset to default layout |
|
||||
|
|
|
|||
|
|
@ -5,6 +5,66 @@
|
|||
These are defined on .dxbl-theme-fluent class and inherited by child elements.
|
||||
*/
|
||||
|
||||
/* Grid toolbar + quick-filter column: stacks the filter panel BELOW the toolbar
|
||||
(the grid's toolbar region lays its children out horizontally otherwise) */
|
||||
.mg-grid-toolbar-with-filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Grid quick-filter panel — composed into the grid's ToolbarTemplate, so it physically lives INSIDE
|
||||
.dxbl-grid-toolbar-container. Styled to read as its OWN band below the toolbar, aligned with the native
|
||||
group panel ("Drag a column header...") band: it reuses the grid's OWN group-panel padding tokens, which
|
||||
the grid root redefines per SizeMode — so the band's left edge and height track the group panel exactly
|
||||
at every grid size. Separator above = our border-top (grid border tokens); separator below = the toolbar
|
||||
container's own native border-bottom (its padding is zeroed below, so that border sits directly under
|
||||
this band — one line, no double border, no extra gap). */
|
||||
.mg-grid-filter-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--DS-sizing-80, 0.5rem);
|
||||
padding: var(--dxbl-grid-group-panel-container-padding-y, 0.5rem) var(--dxbl-grid-group-panel-container-padding-x, 0.75rem);
|
||||
border-top: var(--dxbl-grid-border-width, 1px) var(--dxbl-grid-border-style, solid) var(--dxbl-grid-border-color, #dee2e6);
|
||||
}
|
||||
|
||||
/* The toolbar container's own padding wrapped BOTH rows (toolbar + filter band), which broke the band
|
||||
illusion: the band's separators stopped short of the grid's edges, and below the band the container's
|
||||
leftover bottom padding + its native border produced a second line and an asymmetric gap. Zero the
|
||||
container's padding when it hosts our stacked wrapper; each row then supplies its own padding. */
|
||||
.dxbl-grid-toolbar-container:has(.mg-grid-toolbar-with-filter) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Toolbar row inside the wrapper: carries the toolbar-container's native padding tokens (SizeMode-adaptive),
|
||||
so the toolbar itself looks exactly as it does without a filter panel. */
|
||||
.mg-grid-toolbar-row {
|
||||
padding: var(--dxbl-grid-toolbar-container-padding-y, 0.5rem) var(--dxbl-grid-toolbar-container-padding-x, 0.75rem);
|
||||
}
|
||||
|
||||
/* Quick-filter controls: sit side by side and share the row width; wrap to the next line only when
|
||||
they can't fit. Overrides the editors' default (near) full-width so they don't stack one-per-row.
|
||||
flex-basis / min / max are tunable if a different density is wanted. */
|
||||
.mg-grid-filter-panel > * {
|
||||
flex: 1 1 240px;
|
||||
min-width: 200px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
/* Built-in Clear button: right-aligned, natural size — exempt from the adaptive control sizing above. */
|
||||
.mg-grid-filter-panel > .mg-grid-filter-panel-clear {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Toggled-off state: the panel stays in the render tree (controls must stay alive & registered so the grid
|
||||
can reconcile/clear their criteria — no invisible panel filter), only its box is hidden. */
|
||||
.mg-grid-filter-panel.mg-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Main panel - uses DevExpress Design System variables */
|
||||
.mg-grid-info-panel {
|
||||
background-color: var(--DS-color-surface-neutral-subdued-rest, #f8f9fa);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue