diff --git a/docs/adr/0004-signalr-datasource-server-side-filtering.md b/docs/adr/0004-signalr-datasource-server-side-filtering.md index b60f7ac..2c2beaa 100644 --- a/docs/adr/0004-signalr-datasource-server-side-filtering.md +++ b/docs/adr/0004-signalr-datasource-server-side-filtering.md @@ -61,7 +61,11 @@ Adopt a **criteria-string** server-side filter for the SignalR DataSource. The D **Follow-ups required:** - File SIGDS + GRID TODO entries: `[FilterCriteriaParam]` attribute (lives beside the other SignalR attributes in AyCode.Services; XML `` MUST state the mandatory last-position + optional convention) + hub injection + registration-time convention validation + **mismatch warning-log** (AyCode.Services.Server); `SignalParams.FilterCriteria` field (AyCode.Services); filter-service — zero-config whitelist default + parse/length hard-reject + criteria Debug-log (AyCode.Services.Server); MgGrid `FilterMode` (Local / Server) + criteria serialization into `SignalParams.FilterCriteria` on `Server` (AyCode.Blazor); add `DevExpress.Data` to the server plugin. `[NotGridFilterable]` (AyCode.Core) is **deferred** — TODO-only when the filter-service lands. - **Enabled extension — IMPLEMENTED (2026-07-16), local mode**: MgGrid declarative **quick-filter panel**. Shipped shape (simplified from the earlier two-wrapper draft): a `FilterPanel` RenderFragment slot on `MgGridBase` that **itself wraps** its content in the styled `.mg-grid-filter-panel` container and renders it **below the toolbar** (composed into the grid's `ToolbarTemplate`; 2026-07-16) — no separate panel component (the planned `MgGridFilterPanelBase` → `GridFilterPanel` pair was built, then removed as redundant nesting). Consumers place `MgGridFilterPanelTagBox` components directly in the slot, each with a `FieldName` (consistent with grid columns / DevExpress `FieldName`). **Seam split (2026-07-16):** `MgTagBox` stays a pure, behavior-free `DxTagBox` seam (universal — no grid wiring; a grid-wired universal seam would even clobber a consumer's `@bind-Values`); the grid filter component is the derived `MgGridFilterPanelTagBox : MgTagBox, IMgGridFilterPanelComponent` — future filter components (dropdown/textbox/checkbox) implement the same contract over their own Mg* seams, with the shared glue to be extracted into a helper when the second one lands. On selection each component calls `DxGrid.SetFieldFilterCriteria(FieldName, criteria)` via `IMgGridBase.SetFilterPanelCriteria` (2026-07-16 rename: the earlier `*QuickFilter*` member/type names unified onto the component's `FilterPanel` name); DevExpress **AND-combines the per-field criteria automatically** (+ the user's filter row) — no manual aggregation, no clobber (**NOT** `SetFilterCriteria`, which replaces the whole filter). **Ownership nuance:** the per-field criteria slot is SHARED with the grid's filter row — the panel only manages criteria its component recognizes as its own shape (`IMgGridFilterPanelComponent.OwnsCriteria`; for the tag box the `IN(FieldName, ...)` it writes), so a value typed into the filter row (visible UI) survives panel hide/clear. The panel has a built-in right-aligned **Clear** button (`IMgGridBase.ClearFilterPanel`, enabled only while a panel-owned criteria is active) clearing the panel-owned filters only. A toolbar **Show/Hide Filters** toggle (`IMgGridBase.HasFilterPanel` / `IsFilterPanelVisible` / `ToggleFilterPanel`) appears only when the grid has a filter panel. Implementation notes: imperative `Values` writes inside DevExpress editors must be wrapped in `BeginUpdate()`/`EndUpdate()` (parameter-tracker throws otherwise); first consumer: Partner grid (Currency tag box). The grid's `FilterCriteria` is the single source: locally it filters the bound `Data`; under `FilterMode.Server` the same `FilterCriteria` reaches `GridCustomDataSource.GetItemsAsync(options.FilterCriteria)` → the criteria-string path — so the component is built **once** for both. Needs a server distinct-values source under `FilterMode.Server` (see v2 paging). - **Persistence & visibility reconcile (2026-07-16):** the panel criteria persists with the grid layout (`GridPersistentLayout.FilterCriteria` — the AutoSave tier and the server-roamed UserSave tier alike, so it can arrive on another machine/browser). The panel components register with the grid (`IMgGridFilterPanelComponent`, weakly held) and the grid **reconciles** them after layout restore / user-layout load / layout reset / panel toggle: visible panel → each control re-reads and re-selects its field's criteria (late-arriving async lookup `Data` re-syncs on parameter update); hidden panel → the controls' **own** criteria are **cleared** — a panel filter must never filter invisibly (this also means hiding the panel deliberately discards the selection); criteria the control does NOT own (shared per-field slot with the filter row — e.g. `Contains("EUR")` typed into the column filter) is left untouched. The panel content is always rendered (CSS-hidden when toggled off) so the controls stay registered — that is how the grid knows *which* fields are panel-owned. Known edge: a roamed layout landing where the grid has **no** `FilterPanel` at all cannot be selectively cleared — AyCode.Blazor `MGGRID_ISSUES.md` **ACBLAZOR-GRID-I-D4T7** (proposed fix there + planned next step: an MgGrid layout envelope, e.g. `MgGridLayoutSave { GridLayoutJson, FilterPanelLayoutJson }`, separating the panel state from the DevExpress layout at save time). + **Persistence & visibility reconcile (2026-07-16):** the panel criteria persists with the grid layout (`GridPersistentLayout.FilterCriteria` — the AutoSave tier and the server-roamed UserSave tier alike, so it can arrive on another machine/browser). The panel components register with the grid (`IMgGridFilterPanelComponent`, weakly held) and the grid **reconciles** them after layout restore / user-layout load / layout reset / panel toggle: visible panel → each component re-reads and re-selects its field's criteria; hidden panel → the components' **own** criteria are **cleared** — a panel filter must never filter invisibly (this also means hiding the panel deliberately discards the selection); criteria the component does NOT own (shared per-field slot with the filter row — e.g. `Contains("EUR")` typed into the column filter) is left untouched. The panel content is always rendered (CSS-hidden when toggled off) so the components stay registered — that is how the grid knows *which* fields are panel-owned. Known edge: a roamed layout landing where the grid has **no** `FilterPanel` at all cannot be selectively cleared — AyCode.Blazor `MGGRID_ISSUES.md` **ACBLAZOR-GRID-I-D4T7**. + + **Restore race — root cause + LEVEL-TRIGGERED heal (2026-07-17, multi-agent audit):** a restore has **two** inputs arriving in **arbitrary order** — the criteria (async layout restore via JS interop) and the lookup `Data` (async load). Consumers routinely bind a **pre-created empty** collection that is later filled **in place** (`AcObservableCollection.Replace`), and DevExpress **snapshots the editor's item list, detecting `Data` changes BY REFERENCE ONLY** — so an in-place fill leaves the editor holding an empty list and *no* asserted `Values` can resolve into tags (re-rendering does not help; `DxListEditorBase.Reload()` is the only re-read, and it carries no documented settle guarantee). Two successive **edge-triggered** recovery gates (reference-change, then reference+count-diff) each shipped and each failed the same way: they require an *arming* pass that records the pre-fill state, so the **last-filled** lookup — whose fill lands after its last parameter pass — is systematically stranded (rows filter, `Clear` enabled, chips empty). The audit refuted the `TextFieldName`-type, windowed-data, nullable-column, column-interference and shared-collection hypotheses; the surviving cause is purely this edge-vs-level mismatch. **Fix:** the component now tracks the *standing fact* "this editor's snapshot was taken while `Data` was empty" — armed on **every** parameter pass regardless of criteria presence, cleared only by a heal — and heals (`Reload()` + fresh-instance `Values`) from **both** `OnParametersSet` **and** `SyncFromGrid` (so `IMgGridBase.RefreshFilterPanel()` finally honours its documented contract), plus one deferred post-render re-assert covering `Reload()`'s undocumented settle timing. + + **Planned v2 (supersedes the heal):** fold the pull model into the already-planned layout envelope (`MgGridLayoutSave { Version, GridLayoutJson, FilterPanelLayoutJson }`): strip panel-owned criteria from the DX layout at **save** time (when registered components *can* answer `OwnsCriteria`) and store them as plain `field → values`; on restore **park** them and apply criteria **and** selection atomically only once the owning component is ready (registered **and** `Data` non-empty). This makes "can this criteria be displayed yet?" true **by construction** instead of detected: no `Reload()` dependency, no rows-filtered-while-chips-empty window, `ACBLAZOR-GRID-I-D4T7` fixed by construction, and the pending-reconcile flag, the snapshot heal and `RefreshFilterPanel()` all become deletable (net complexity **down**, consumer boilerplate → zero). Standalone pull (without the envelope) was **rejected**: at auto-load time no component is registered yet, forcing shape-sniffing of the DX criteria string, which collides with the shared filter-row slot. An `INotifyCollectionChanged` subscription was also **rejected**: strong collection→component references on page-shared collections outliving `OnDemand` tab components, in a design that holds components weakly precisely because the DX seams expose no disposal hook — a lifetime bug traded for a timing bug, fixing none of the structural issues. Design decision to record when v2 lands: a parked entry with no owner this session stays parked and is **re-persisted** on save, so a temporarily absent panel cannot silently destroy a saved filter. - Verify: `DevExpress.Data` version matching the client DevExpress Blazor major; license coverage on the server package feed; linq2db translation of `CriteriaToExpressionConverter` output (function whitelist). - **Quick-search (`SearchText`) — confirmed SEPARATE from `FilterCriteria`.** Per DevExpress docs (Filter API): *"Search settings do not affect the applied filter criteria"* — `SearchText` filters in parallel, NOT folded into `FilterCriteria`. So server-side search does **not** ride the criteria-string path for free; it needs its **own** handling: a thin `SearchText → OR-Contains criteria` adapter over searchable columns (then it rides the same channel), or a separate search field. Same whitelist / DoS caveats apply (search = OR-Contains across many columns). - AyCode.Blazor cross-ref: no `docs/adr/` folder there yet → note in MGGRID docs or a future AyCode.Blazor ADR.