Almost every non-trivial custom report or bulk action I've built on Acumatica needed some kind of "ask the user a few questions first" step - a date range, a branch filter, an output format toggle - before the actual work runs. PXFilter is the mechanism for that: a filter DAC bound to a popup dialog, backed by a normal cache like any other data view, but with a few behaviors specific to how filter values persist and clear that trip people up the first time they build one.
A filter is just a DAC, usually unbound, wired to PXFilter<T>
The filter's fields are typically unbound (no PXDBXxx attribute, just PXUIField plus a plain type attribute like PXString or PXDate) because filter criteria usually don't need their own table - they're transient input the graph reads once per invocation. PXFilter<TFilter> is the data view type that binds this DAC to the dialog UI and persists the single current filter row per user, per screen, across sessions unless you explicitly clear it.
[Serializable]
[PXHidden]
public class ReportFilter : IBqlTable
{
#region BranchID
[Branch]
public virtual int? BranchID { get; set; }
public abstract class branchID : PX.Data.BQL.BqlInt.Field<branchID> { }
#endregion
#region StartDate
[PXDBDate]
[PXUIField(DisplayName = "Start Date")]
[PXDefault(typeof(AccessInfo.businessDate))]
public virtual DateTime? StartDate { get; set; }
public abstract class startDate : PX.Data.BQL.BqlDateTime.Field<startDate> { }
#endregion
#region IncludeClosed
[PXDBBool]
[PXUIField(DisplayName = "Include Closed Orders")]
[PXDefault(false)]
public virtual bool? IncludeClosed { get; set; }
public abstract class includeClosed : PX.Data.BQL.BqlBool.Field<includeClosed> { }
#endregion
}
public PXFilter<ReportFilter> Filter;
The behavior that surprises people first: filters persist per user, silently
Unlike a normal transient data view, PXFilter's current row survives across screen visits for the same user - close the dialog, come back tomorrow, and the last-entered values are still there by design, stored against the user's preferences rather than re-defaulting every time. This is usually what you want for a report filter (nobody wants to re-pick the same branch every day), but it actively bites you if your defaulting logic assumes a fresh row on every open. A PXDefault attribute only applies the first time a row is created; once persisted, the stored value wins on every subsequent load, silently overriding what looks like a sensible default in the code.
The single most common "why does my default not apply" bug with PXFilter is testing by repeatedly clicking the report action as the same user - the second and every subsequent click reuses the persisted filter row from the first, so a PXDefault change never visibly takes effect during that testing session. It looks like the code didn't take when the code is fine; the stored filter state is just masking it.
FieldUpdated on filter fields works exactly like any other DAC
Because the filter is a real cache-tracked DAC, the full event pipeline applies to it - FieldUpdated fires when the user changes a value in the dialog, which is the normal way to make one filter field conditionally control another (hide "Include Sub-Branches" unless a specific branch is picked, for instance):
protected virtual void _(Events.FieldUpdated<ReportFilter.branchID> e)
{
ReportFilter row = (ReportFilter)e.Row;
if (row == null) return;
bool specificBranch = row.BranchID != null;
PXUIFieldAttribute.SetVisible<ReportFilter.includeSubBranches>(
Filter.Cache, row, specificBranch);
}
Reading the filter to drive an action or report
The pattern I use for a report or bulk action driven by a filter: a button action reads Filter.Current, builds a BQL query or a PXLongOperation from those values, and never assumes the filter fields are non-null even with PXDefault attributes present, because a user can always clear a field manually before running the report.
public PXAction<ReportFilter> RunReport;
[PXUIField(DisplayName = "Run")]
[PXButton]
protected virtual IEnumerable runReport(PXAdapter adapter)
{
ReportFilter filter = Filter.Current;
if (filter?.StartDate == null)
throw new PXException("Start date is required.");
var results = SelectFrom<SOOrder>
.Where<SOOrder.orderDate.IsGreaterEqual<P.AsDateTime>
.And<SOOrder.branchID.IsEqual<P.AsInt>.Or<P.AsInt.IsNull>>>>
.View.Select(Base, filter.StartDate, filter.BranchID, filter.BranchID);
// ... build and return the report output
return adapter.Get();
}
When you do want a fresh dialog every time
If persistence genuinely isn't wanted for a specific filter, clear it deliberately at the point the screen or graph initializes rather than fighting PXDefault semantics - Filter.Cache.Clear() followed by re-inserting a fresh row forces new defaults to apply, which is the honest way to opt a specific filter dialog out of the normal persisted behavior instead of trying to work around it field by field.
Wrapping up
PXFilter dialogs are ordinary cache-tracked DACs with one behavior that isn't obvious until it bites you: values persist per user across sessions by design, which means PXDefault only ever controls the very first row, not every subsequent open. Build conditional dialog behavior through the same FieldUpdated pipeline you'd use anywhere else, always null-guard filter values before using them to drive a report or action, and clear the filter's cache explicitly on the rare occasion you actually want a clean slate every time.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.