Acumatica · Customization

Acumatica PXTree Control — Population and Events

A client needed a category picker for a custom catalog structure - parent categories, subcategories, several levels deep - and wanted it presented the way Acumatica presents its.

John Kihiu12 min read

A client needed a category picker for a custom catalog structure - parent categories, subcategories, several levels deep - and wanted it presented the way Acumatica presents its own Company Tree or Account Groups: an actual expandable tree control, not a flattened dropdown pretending to show hierarchy through indentation. PXTree is the control for that, and it's one of the less-documented corners of the framework, mostly because so few customizations actually need real hierarchical UI. Here's what I learned building it.

The data model underneath is a self-referencing DAC, nothing exotic

A tree control needs a DAC where each row optionally points to its own parent - the same self-referencing foreign key pattern used for Company Tree nodes and Account Groups internally. Nothing about the DAC itself is tree-specific; the hierarchy comes entirely from a nullable parent ID field and the tree control's own logic for interpreting it.

C# · A self-referencing category DAC
[Serializable]
[PXCacheName("Catalog Category")]
public class CatalogCategory : IBqlTable
{
    #region CategoryID
    [PXDBIdentity(IsKey = true)]
    public virtual int? CategoryID { get; set; }
    public abstract class categoryID : PX.Data.BQL.BqlInt.Field<categoryID> { }
    #endregion

    #region ParentCategoryID
    [PXDBInt]
    [PXSelector(typeof(CatalogCategory.categoryID))]
    [PXUIField(DisplayName = "Parent Category")]
    public virtual int? ParentCategoryID { get; set; } // null = root node
    public abstract class parentCategoryID : PX.Data.BQL.BqlInt.Field<parentCategoryID> { }
    #endregion

    #region Name
    [PXDBString(60, IsUnicode = true)]
    [PXUIField(DisplayName = "Category Name")]
    public virtual string Name { get; set; }
    public abstract class name : PX.Data.BQL.BqlString.Field<name> { }
    #endregion
}

PXTree needs a view shaped for hierarchy, not a flat select

The screen-side wiring pairs a normal PXSelect view with tree-aware markup on the ASPX (or the equivalent Modern UI container), where the tree control is told which field is the node's own ID and which field is its parent pointer. The graph itself doesn't need special logic to make hierarchy work - the tree control walks the parent/child relationship client-side from whatever rows the view returns.

ASPX · Tree control bound to the self-referencing fields
<px:PXTreeDataMember runat="server" ID="ds" DataMember="Categories"
    Style="Full" NavigateUrlFormat="~/Main.aspx?CategoryID={0}" />
<px:PXTreeView runat="server" ID="tree" DataSourceID="ds"
    ...
    ValueField="CategoryID" ParentField="ParentCategoryID"
    TextField="Name" />

Population order: parents must exist in the result set before children make sense

The tree control builds its structure from whatever the view returns in a single pass, matching each row's parent field against other rows' ID field in that same result set. If your view filters out root nodes (parent is null) for some reason - a poorly written WHERE clause meant to exclude something else that accidentally excludes roots too - every remaining row becomes effectively orphaned, and the control either shows nothing or shows every node flattened as if it were a root, depending on how the specific tree implementation handles missing parents. This was the actual bug in my first pass at the catalog picker: a status filter I'd added to hide inactive categories also filtered out an inactive root category that several active subcategories still pointed to, orphaning an entire branch.

Filtering a tree's source view can silently break the hierarchy, not just hide rows

A WHERE clause on a flat grid just hides rows you don't want to see - nothing else depends on them being present. On a tree, hiding a node also orphans every descendant of that node unless your filter logic accounts for ancestry. If you need to filter a tree (show only active categories, for instance), filter by ensuring the entire ancestor chain to any visible node is also included, not just the node itself.

Full-tree load vs on-demand expansion

For a genuinely large hierarchy - thousands of nodes, many levels deep - loading the entire tree in one BQL select on screen open is wasteful when a user will only ever expand a handful of branches in a session. PXTree supports on-demand population, where child nodes are fetched only when a parent node is expanded, driven by a callback rather than one upfront select. I only reach for this once the flat-load approach actually shows a measurable delay on real data; for the catalog picker's few hundred categories, a single upfront select was simpler and fast enough, and I'd recommend starting there rather than building lazy-load complexity speculatively.

Using the tree as a picker, not just a navigator

When the tree is meant to select a value into another screen's field (as opposed to being a pure navigation control like Company Tree), the selection event needs to write the chosen node's ID back into the target DAC field through the normal cache-driven update path - same FieldUpdated-triggered cascade you'd use for any other selector, just fed by a tree click instead of a grid row click or a PXSelector lookup.

Wrapping up

PXTree's data model is nothing more exotic than a self-referencing parent ID on an ordinary DAC - the complexity lives entirely in getting the source view's population right, not in any tree-specific graph logic. Watch filtering carefully: excluding a node from a tree's source view orphans its descendants unless the filter accounts for the full ancestor chain, and that's the bug that actually costs time, not the control's setup.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.