Custom column-filter UI for DevExpress XAF (Blazor Server) — five drop-in filter menus that replace the default DevExpress filter dropdown on a per-column-type basis, plus a fully tested demo app to prove they work.
The eventual deliverable is a reusable filter / query-builder library that drops into any XAF application; this repo is the proving ground.
| Stack | .NET 10, DevExpress XAF 25.2.5 (Blazor Server), EF Core 10, SQL Server LocalDB |
| Tests | 55 xUnit (criteria builders + demo seeder, sub-second) + 9 Playwright (Blazor + filter UI, ~80s) |
| Build configurations | Debug, Release, EasyTest (auto-creates a separate XafFilterEasyTest DB — used by the Playwright fixture) |
Five custom column-filter menus, automatically wired by column member type:
| Filter | Targets | UI |
|---|---|---|
DateRangeFilterMenu |
DateTime, DateOnly |
Two DxDateEdit (From / To) |
NumericRangeFilterMenu |
All numeric types (int, decimal, double, ...) |
Two DxSpinEdit with N0 / N2 format |
WildcardStringFilterMenu |
string |
Single DxTextBox accepting _ and % |
EnumMultiSelectFilterMenu |
enum types | DxListBox with one checkbox per enum value |
BoolTriStateFilterMenu |
bool |
DxRadioGroup with All / Yes / No |
Plus a [DisableCustomFilter] opt-out attribute for fields where the type-based heuristic picks the wrong filter (ID columns, legacy import keys, etc.).
A guided tour through the demo app's Ticket ListView.
Ticket is the demo BO; Customer and Agent are referenced from it. The seeder uses Bogus with a deterministic Randomizer.Seed = new Random(42), so each row count produces the same data every run.
The Generate Demo Data toolbar action is a PopupWindowShowAction on the Ticket ListView. It collects a row count + date range + clear-first flag via a [DomainComponent] non-persistent BO, then runs the seeder in a fresh IObjectSpace and refreshes the view.
Click any column's funnel icon to open the filter menu. For DateTime / DateOnly columns the menu becomes a From/To date-range picker.
Numeric columns get a From/To DxSpinEdit pair. Integer columns use N0 format, floating-point types use N2.
String columns get a single text input that accepts SQL LIKE wildcards (_ for one char, % for many).
Enum columns render a DxListBox with a checkbox per value. Empty or all-selected = no filter.
bool columns get a three-way radio: All clears the filter; Yes and No filter to true / false.
Ticket.LegacyImportId is decorated with [DisableCustomFilter], so the default DevExpress filter menu (Select All + checkbox list) appears instead of the custom NumericRangeFilterMenu.
Every custom filter follows the same lifecycle:
Controller.OnActivatedsubscribes toView.ControlsCreated.Controller.View_ControlsCreatediterateseditor.GridDataColumnModelson theDxGridListEditor, skips columns whoseMemberTypedoesn't match this filter or whose property is marked[DisableCustomFilter], then setsFilterMenuButtonDisplayMode = Alwaysand assigns aFilterMenuTemplatethat renders the Razor component.Razor.OnParametersSetcalls aCriteriaBuilders.ReadXxxhelper to recover the current inputs fromFilterContext.FilterCriteria.Razor.OnInputChangedcallsCriteriaBuilders.BuildXxxand writes the result back toFilterContext.FilterCriteria. The grid re-filters on every input change — the popup's Apply button stays disabled because there are no pending criteria; the popup closes via Cancel or click-outside.Controller.OnDeactivatedunsubscribesControlsCreated.
All criteria construction lives in XafFilter.Module/Filters/CriteriaBuilders.cs — pure helpers, no Blazor dependency, fully unit-testable.
The Razor components and their controllers are in XafFilter.Blazor.Server/Filters/ — paired by name (DateRangeFilterMenu.razor + DateRangeFilterMenuController.cs).
XafFilter/
├── XafFilter.slnx ← solution file (new XML format)
├── XafFilter/
│ ├── XafFilter.Module/ ← business objects, criteria builders
│ │ ├── BusinessObjects/Demo/ ← Customer, Agent, Ticket, GenerateDemoDataParameters
│ │ ├── Controllers/ ← GenerateDemoDataController (platform-agnostic)
│ │ ├── DemoData/DemoDataSeeder.cs ← Bogus-powered deterministic seeder
│ │ ├── Filters/
│ │ │ ├── CriteriaBuilders.cs ← Build/Read pair per filter type
│ │ │ └── DisableCustomFilterAttribute.cs
│ │ └── Module.cs, XafFilterDbContext.cs
│ ├── XafFilter.Blazor.Server/ ← host
│ │ ├── Filters/
│ │ │ ├── Components/*.razor ← five filter Razor components
│ │ │ └── Controllers/*.cs ← five Blazor-side controllers
│ │ ├── Startup.cs, Program.cs, BlazorApplication.cs
│ │ └── appsettings.json ← LocalDB connection + EasyTest connection
│ ├── XafFilter.Module.Tests/ ← xUnit — criteria builders, seeder
│ └── XafFilter.Blazor.Server.Tests/ ← Playwright — smoke + filter UI + theme
└── docs/
├── superpowers/plans/ ← implementation plan that drove this work
└── screenshots/ ← README screenshots
Prerequisites:
- .NET 10 SDK
- SQL Server LocalDB (the Visual Studio installer's "Data storage and processing" workload, or stand-alone LocalDB installer)
- A trusted ASP.NET Core dev certificate:
dotnet dev-certs https --trust
Start the host:
dotnet run --project XafFilter/XafFilter.Blazor.ServerThe first run creates the XafFilter LocalDB database via Updater.cs when launched under a debugger (Visual Studio F5). From the CLI without a debugger, use the EasyTest configuration — it always runs the updater and points at the separate XafFilterEasyTest catalog:
dotnet run --project XafFilter/XafFilter.Blazor.Server -c EasyTestThen open https://localhost:5001 and log in as Admin (blank password). Navigate to Ticket via direct URL (https://localhost:5001/Ticket_ListView) and click Generate Demo Data to seed.
Demo BO nav items are intentionally not in the side menu — the project's focus is the filter UI, not the demo CRUD. URL-route directly to
/Ticket_ListView.
Two things must happen or the type silently fails to appear in the XAF model:
- Add a
DbSet<T>toXafFilter.Module/BusinessObjects/XafFilterDbContext.cs. - Add
AdditionalExportedTypes.Add(typeof(T));to theXafFilterModuleconstructor inModule.cs.
And for navigation collections, always use ObservableCollection<T>, not List<T> — XAF's ChangingAndChangedNotificationsWithOriginalValues change-tracking strategy requires INotifyCollectionChanged. List<T> compiles and even unit-tests fine (the in-memory test DbContext uses a different strategy) but throws at CreateObject<T>() runtime in the real app. This bit Customer.Tickets and Agent.AssignedTickets once — see commit c987aea.
# Unit tests — criteria Build/Read round-trips + DemoDataSeeder. ~1s.
dotnet test XafFilter/XafFilter.Module.Tests
# Playwright tests — login + filter UI rendering + light-theme. ~80s.
# First run downloads Chromium to ~/.cache/ms-playwright/.
dotnet test XafFilter/XafFilter.Blazor.Server.Tests
# Everything.
dotnet test XafFilter.slnxAppFixture spawns dotnet run -c EasyTest --no-launch-profile --urls=http://localhost:5000 from the test process, with ASPNETCORE_ENVIRONMENT=Development forced. The fixture then polls GET /LoginPage until it returns 200, opens Chromium headless, and exposes a NewLoggedInContextAsync() helper for individual tests.
Reasons for the non-obvious choices:
- HTTP/5000, not HTTPS/5001 — the default Blazor Server SignalR connection over
wss://fails its handshake against the untrusted dev cert in headless Chromium, even withIgnoreHTTPSErrors = trueon the browser context. The Blazor circuit never starts, the page stays on the XAF splash logo. HTTP avoids the entire problem. ASPNETCORE_ENVIRONMENT=Developmentexplicit — without--launch-profilethe host defaults toProduction, which makesUseStaticWebAssets()skip the_contentmanifest, andblazor.server.js404s.-c EasyTest— theDatabaseVersionMismatchhandler inBlazorApplication.csonly auto-runs theUpdaterwhen the EASYTEST symbol is defined or a debugger is attached.
The screenshots in docs/screenshots/ are generated by ReadmeScreenshots.cs. It's [Skip]'d by default — un-skip it, run the test once, then put the skip back.
This project was implemented with Claude Code and several project-local skills live under .claude/skills/:
/run-xaf— starts/stops the Blazor host with a port-free check + HTTP health probe./xaf-filter-notes— domain reference for filter / criteria-editor work in XAF, including the 5-step contract documented above and the[DisableCustomFilter]opt-out pattern.
User-global skills also used heavily:
xaf-efcore-entities— the XAF EF Core authoring rules (virtual properties,ObservableCollection, decimal precision,[Aggregated]cascade, etc.).xaf-viewcontroller-patterns— controller lifecycle, BoolListActive/Enabled,PopupWindowShowActionpatterns.
MIT © 2026 Martin Brekhof







