Tree component
Tree is designed for product categories, organization charts, folders, regions, and other hierarchical data. You can maintain a small tree directly in the page structure, or load roots and children from an API. The same component also supports search, node-level actions, and drag-and-drop reordering.
This guide covers how to:
- Build a small static navigation tree
- Load a complete hierarchy or fetch children on demand
- Map API fields to the node title, value, leaf state, and secondary text
- Search locally or through a backend endpoint
- Use click, check, expand, load, and drop context
- Add a shared More menu and desktop context menu
- Persist a new hierarchy through
onDrop - Control the tree with
reloadTree,expandAll, andcollapseAll
1. Choose the right setup
Start with the way the hierarchy is maintained:
| Scenario | Recommended setup | Read next |
|---|---|---|
| A short, stable list maintained by page authors | Static Tree Nodes | Static nodes, Refresh content when a node is selected |
| Data owned by a business system | Tree Data Source | Dynamic data source, Search |
| Add, edit, delete, or contextual commands | Dynamic source + Node Actions | Configure node actions |
| Reorder siblings or move branches | Dynamic source + onDrop | Drag and persist changes |
The two Spark Sample pages below illustrate the static and dynamic approaches. You do not need access to the Sample application to reproduce them.
1.1 Tree: static product categories
The Tree page keeps a small product taxonomy in the page structure. Selecting a node writes its category to Page Context, then reloads the ProTable on the right.
Screen
├── Navigation Tree
│ └── Tree
│ └── Tree Nodes
│ ├── All Products (ALL)
│ ├── Computing (COMPUTING)
│ ├── Displays (DISPLAY)
│ └── Accessories (ACCESSORY)
└── Panels
└── ProTable
The important part is the order of the interaction chain:
- Store
[[treeNode.name]]inselectedCategorywith Set Page Context. - Call the ProTable
reloadTablefunction with Component API. - Map
selectedCategoryto thecategoryrequest parameter in the ProTable Query Context.
See Refresh content when a node is selected for the full editor setup.
1.2 Tree02: dynamic loading and hierarchy editing
Tree02 combines a backend-powered Tree with a ProTable and node commands:
| Capability | Sample behavior |
|---|---|
| Roots and children | Loads roots when the page opens, then requests direct children with parentId |
| Search | Sends the term to the server and keeps the ancestors of matching nodes |
| Detail panel | Writes id and category to Page Context before reloading the ProTable |
| Node commands | Uses the same Edit, Add Child, View Details, and Delete items for More and right-click |
| Drag-and-drop | Saves through onDrop; the visual move is rolled back if persistence fails |
Build the example in stages: verify the root response first, add lazy loading and search next, then configure click behavior and node actions. Turn on dragging only after the read path is working. This makes failures much easier to isolate.
2. Add a Tree
- Open the target page in Page Designer.
- Select Screen in the page structure.
- Add Tree under the Screen's Navigation Tree child area.
- For static data, add entries under Tree Nodes.
- For API-backed data, add Tree Data Source under Data Source.
- Add ProTable, Form, or other content under Panels.
Navigation Tree is the dedicated left-hand region of Screen. Panels and Grids automatically form the content region, so there is no need to recreate this layout with a Grid.
3. Configure the Tree
| Property | Example | Purpose |
|---|---|---|
| Title | Dynamic Organization | Optional heading above the tree |
| Width | 320px | Desktop width in pixels; the tree becomes a full row on narrow screens |
| Show Search | On | Displays the search field |
| Show Icon | On | Displays folder and leaf icons |
| Selectable | On | Allows selection and fires onClick |
| Show Connecting Lines | On | Shows the parent-child guides |
| Show Checkbox | Off | Enables multi-selection when needed |
| Strict Mode | Off | Stops parent and child checkbox states from affecting each other |
| Node Draggable | On | Allows runtime reordering |
| Drag Scope | SAME_PARENT | Restricts moves to siblings or allows cross-branch movement |
| Node Action Trigger | MORE_BUTTON_AND_CONTEXT_MENU | Keeps the More button and enables desktop right-click |
SAME_PARENT allows only sibling reordering. ANY_LEVEL also allows moving a node to another branch; Tree still prevents a parent from being moved inside one of its descendants. Keep Node Draggable off when the tree is navigation-only.
4. Static nodes
Each static Tree Node needs at least:
| Property | Example | Purpose |
|---|---|---|
| Name | ACCESSORY | Stable internal value used in context or API parameters |
| Title | Accessories | User-facing label |
Keep Name stable across languages. Localize Title instead.
A node with Children is treated as a folder; a node without Children is a leaf. A dynamic Tree may still include a few static entries for design-time fallback. Once a dynamic request succeeds, its response becomes authoritative.
5. Dynamic data source
Add Tree Data Source under the Tree's Data Source area. Configure the root request and mappings before adding lazy loading or remote search:
- Open Data Source and choose Add data source, or edit the existing source.
- Select Tree Data Source.
- Fill in the basic properties below.
- Select Test and confirm that the response produces nodes.
- Open Advanced and add child-loading and search settings.
- Test the root request again, then save.
| Property | Tree02 value | Purpose |
|---|---|---|
| Root Data URL | /spark/api/sample/navigation/tree/nodes | Returns roots or a complete tree |
| Request Method | GET | GET or POST |
| Request Parameters | As needed | May reference Page Context, components, and browser values |
| Response List Path | data.nodes | Path to the node array; leave blank to detect common shapes |
| Title Field | label | User-facing node text |
| Value Field | id | Stable, unique node value |
| Children Field | children | Nested child array |
| Leaf Field | leaf | Boolean indicating that a node has no children |
| Disabled Field | disabled | Boolean indicating that a node cannot be selected |
| Meta Field | count | Secondary text, such as the child count |
| Initial Load | On | Requests roots when the page opens |
Root Data URL may be an application-relative path, an absolute URL, or a relative path resolved through Connection Key.
5.1 Test the request and inspect the response
Test calls the configured Root Data URL. Spark automatically adds the current environment and design-preview mode. A test from a DEV page carries:
X-Spark-Page-Environment: DEV
X-Spark-Page-Render-Mode: DESIGN_PREVIEW
The result panel shows the HTTP status, duration, final URL, business parameters, Response List Path, masked JSON response, and parsed node count.
If the request returns 200 with zero parsed nodes, check Response List Path first. If nodes are found but labels are blank, check Title Field. For a browser-level failure, verify that CORS allows both X-Spark-Page-* headers.
Test, design preview, and runtime use the same request builder and response parser. Do not add the environment headers manually in Data Source. The full contract is described in Page request context and backend debugging.
The backend resolves the standard headers once and passes the resulting context to the service:
@GetMapping("/nodes")
public ApiResult<NavigationTreeDataResult> nodes(
@RequestParam(required = false) String parentId,
@RequestParam(required = false) String query,
@RequestHeader(value = "Accept-Language", required = false) String language,
@RequestHeader(value = SparkPageRequestHeaders.ENVIRONMENT,
required = false) String environment,
@RequestHeader(value = SparkPageRequestHeaders.RENDER_MODE,
required = false) String renderMode) {
Locale locale = SparkApiResultHelper.resolveLocale(language);
SparkPageRequestContext requestContext =
SparkPageRequestHeaders.resolve(environment, renderMode);
NavigationTreeDataResult result = new NavigationTreeDataResult(
treeService.query(parentId, query, requestContext), requestContext);
return SparkApiResultHelper.success(
locale, result, "SPARK-SAMPLE-NAVIGATION-0002");
}
The service works with shared enums rather than parsing header strings again:
private boolean isDevelopmentPreview(
SparkPageRequestContext requestContext) {
return requestContext != null
&& requestContext.getEnvironment() == SparkPageEnvironment.DEV
&& requestContext.getRenderMode()
== SparkPageRenderMode.DESIGN_PREVIEW;
}
Tree02 returns a deterministic preview hierarchy for DEV + DESIGN_PREVIEW; runtime requests use the data for their target environment. The context headers are routing and debugging signals, not a substitute for authentication, authorization, or tenant isolation.
5.2 Return a complete hierarchy
When one request returns the entire hierarchy, place children in the configured Children Field:
{
"success": true,
"data": {
"nodes": [
{
"id": "catalog",
"label": "Product Catalog",
"count": "2",
"leaf": false,
"children": [
{ "id": "computing", "label": "Computing", "leaf": true, "children": [] },
{ "id": "display", "label": "Displays", "leaf": true, "children": [] }
]
}
]
}
}
Child Nodes URL can remain blank in this mode.
5.3 Load children on demand
For large or deep hierarchies, configure:
| Property | Example | Purpose |
|---|---|---|
| Child Nodes URL | /spark/api/sample/navigation/tree/nodes | Called when a folder is expanded; it may match Root Data URL |
| Parent Parameter | parentId | Receives the current folder's Value |
GET /spark/api/sample/navigation/tree/nodes
GET /spark/api/sample/navigation/tree/nodes?parentId=catalog
With lazy loading, Leaf Field must clearly identify nodes with no children. Otherwise Tree cannot know whether to display the expansion control.
6. Search
By default, search filters only nodes already loaded in the browser and does not change the selection.
For branches that may not have been loaded, enable Server-side Search:
| Property | Example | Purpose |
|---|---|---|
| Search URL | /spark/api/sample/navigation/tree/nodes | Optional; falls back to Root Data URL |
| Search Parameter | query | Parameter carrying the search term |
Typing tier 2 produces:
GET /spark/api/sample/navigation/tree/nodes?query=tier%202
Return each match with its ancestors. A leaf without its path gives users no useful sense of location. Clearing the query reloads the roots.
7. Event context
Tree publishes event context before running the configured interaction.
7.1 Node click: onClick
treeNode.key
treeNode.value
treeNode.name
treeNode.title
treeNode.parentKey
treeNode.parentValue
treeNode.depth
treeNode.isLeaf
treeNode.data
treeNode.extData
For dynamic nodes, the original API record is available as treeNode.data:
[[treeNode.value]]
[[treeNode.data.category]]
Static-node extension fields remain available under treeNode.extData.
7.2 Checkbox change: onCheck
treeCheckedKeys
treeCheckedNodes
7.3 Expand or collapse: onExpand
treeExpand.expanded
treeExpand.node
treeExpand.expandedKeys
7.4 Data loaded: onLoad
treeLoad.parentNode
treeLoad.query
treeLoad.nodeCount
parentNode is empty for a root load and identifies the expanded folder for a lazy child request.
8. Configure node actions
Node Actions are defined once on Tree and apply to static nodes, dynamic roots, and lazy-loaded children. Visibility and disabled conditions can still vary by node.
8.1 Add actions
- Select Tree.
- Add Tree Node Action under Node Actions.
- Enter Title and choose an icon in the shared Icon picker.
- Use
DANGERfor destructive commands. - Enable Divider Before when the item starts a new group.
- Select the action and configure its
onClickinteraction.
The child-element order is the menu order. A divider is never rendered above the first visible item.
8.2 More and right-click
Right-click is not a separate onContextMenu interaction. Both entry points use the same Node Actions:
MORE_BUTTONkeeps only the visible More button.MORE_BUTTON_AND_CONTEXT_MENUalso opens the same menu on desktop right-click.
Tree02 uses:
Tree
└── Node Actions
├── Edit
├── Add Child
├── View Details
└── Delete
| Action | Appearance and condition | Interaction chain |
|---|---|---|
| Edit | edit, DEFAULT | Fill the edit form from treeNode, then open its Drawer |
| Add Child | link, DEFAULT | Set the parent from treeNode.value, clear the name, then open the create Drawer |
| View Details | view, DEFAULT | Fill a read-only form and open the detail Drawer |
| Delete | delete, DANGER, Divider Before; hide when ![[treeNode.isLeaf]] | Reminder, delete request, then reloadTree |
Keep action interactions on the Tree Node Action itself, not on the Tree's onClick.
Edit sends PUT /spark/api/sample/navigation/tree/nodes/[[treeNode.value]]; Add Child sends POST /spark/api/sample/navigation/tree/nodes; Delete sends:
DELETE /spark/api/sample/navigation/tree/nodes/[[treeNode.value]]
Only reload after a successful request. The backend must enforce permissions and delete rules independently; hiding a command is not a security boundary.
8.3 Trigger behavior and keyboard access
More and context-menu triggers do not bubble to selection, checking, expansion, or dragging. Right-click suppresses the browser menu only when the current node has at least one visible action. Touch devices always use More rather than long-press.
Keyboard users can press Enter or Space on More, or Menu / Shift+F10 on a node. Arrow keys move through the menu; Enter or Space runs an item; Escape closes the menu and restores focus.
8.4 Show or disable actions by node
Advanced exposes:
| Property | Effect | Example |
|---|---|---|
| Hide This Action | Hides the item when true | [[treeNode.isLeaf]] |
| Disable This Action | Leaves the item visible but unavailable | [[treeNode.data.locked]] |
Expressions may use !, &&, ||, and common comparisons. An invalid Hide expression fails closed by hiding the item; an invalid Disable expression keeps it disabled. Page Designer also reports a configuration diagnostic.
8.5 Action context
Opening a menu writes the target to treeNode and selectedTreeNode without firing Tree onClick. Choosing an item also publishes:
treeAction.id
treeAction.elementId
treeAction.title
treeAction.tone
Design preview can show the menu and conditions, but it does not run the action chain. Test Drawer, Modal, Reminder, and request behavior after saving the page.
9. Drag and persist changes
Enable Node Draggable to move a node before, inside, or after another node. After the visual move, Tree fires onDrop and exposes:
treeDrop.dragNode
treeDrop.dropNode
treeDrop.position
treeDrop.oldParentKey
treeDrop.newParentKey
treeDrop.oldIndex
treeDrop.newIndex
treeDrop.treeData
treeDrop.position is BEFORE, INSIDE, or AFTER. The move initially affects only the current page. To persist it, add Send Request to onDrop:
POST /spark/api/sample/navigation/tree/reorder
| Parameter | Value |
|---|---|
dragId | [[treeDrop.dragNode.key]] |
dropId | [[treeDrop.dropNode.key]] |
position | [[treeDrop.position]] |
If the request fails or is interrupted, Tree restores the previous structure. The backend must still validate both nodes, the new parent, cycles, permissions, and concurrent changes.
10. Refresh content when a node is selected
Tree publishes the current node; it does not decide how another component should query data. Write the selection to Page Context first, then call the target component.
10.1 Static Tree
For the static product-category example:
- Select Tree, open Interaction, and choose
onClick. - Add Set Page Context, named
Set selected category. - Add
selectedCategory = [[treeNode.name]]in Action Config. - Add Component API, named
Reload product table. - Select the ProTable and its
reloadTablefunction. - Keep Set Page Context before Component API.
In ProTable Query Context, map the category parameter to Page Context path selectedCategory. The backend can treat ALL as no category filter.
10.2 Dynamic Tree
Tree02 follows the same sequence:
- Write
[[treeNode.value]]toselectedNodeIdand[[treeNode.data.category]]toselectedCategory. - Call the ProTable
reloadTablefunction.
Map those values to the request parameters nodeId and category. Category nodes filter by category; the sample backend maps business nodes such as Operations or Support to a suitable product view by nodeId.
11. Component functions
Other interactions can call Tree through Component API:
| Function | Behavior |
|---|---|
reloadTree | Requests the root nodes again and retains a still-valid selection |
expandAll | Expands every directory already loaded in the page |
collapseAll | Collapses all directories |
expandAll does not fetch every unloaded branch of a large lazy tree.
12. Java backend reference
The following contract matches Tree02 and targets Java 17 with Spring Boot 4. The sample uses in-memory data to keep the interaction visible; a production service can replace the storage while retaining the DTO and endpoint contract.
12.1 DTOs and request models
Return a stable id and an explicit leaf flag. A non-leaf node may return an empty children list until it is expanded.
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class NavigationTreeNodeDTO {
private String id;
private String label;
private String parentId;
private String category;
private String count;
private boolean leaf;
private boolean disabled;
private List<NavigationTreeNodeDTO> children = new ArrayList<>();
}
@Getter
@Setter
public class NavigationTreeNodeWriteRequest {
private String parentId;
private String label;
private String category;
}
@Getter
@Setter
public class NavigationTreeReorderRequest {
private String dragId;
private String dropId;
private String position;
}
12.2 Endpoints and page request context
| Use | Method and path |
|---|---|
| Roots, children, and search | GET /spark/api/sample/navigation/tree/nodes |
| Add a child | POST /spark/api/sample/navigation/tree/nodes |
| Edit a node | PUT /spark/api/sample/navigation/tree/nodes/{nodeId} |
| Delete a node | DELETE /spark/api/sample/navigation/tree/nodes/{nodeId} |
| Persist a drag | POST /spark/api/sample/navigation/tree/reorder |
Resolve the standard headers once in the controller. The same endpoint can then return deterministic preview data for Test and environment-specific data at runtime.
@GetMapping("/nodes")
public ApiResult<NavigationTreeDataResult> nodes(
@RequestParam(required = false) String parentId,
@RequestParam(required = false) String query,
@RequestHeader(value = "Accept-Language", required = false) String language,
@RequestHeader(value = SparkPageRequestHeaders.ENVIRONMENT,
required = false) String environment,
@RequestHeader(value = SparkPageRequestHeaders.RENDER_MODE,
required = false) String renderMode) {
Locale locale = SparkApiResultHelper.resolveLocale(language);
SparkPageRequestContext context =
SparkPageRequestHeaders.resolve(environment, renderMode);
NavigationTreeDataResult result = new NavigationTreeDataResult(
treeService.query(parentId, query, context), context);
return SparkApiResultHelper.success(
locale, result, "SPARK-SAMPLE-NAVIGATION-0002");
}
12.3 Lazy loading, search, and preview data
With no parentId, return root nodes. With a parentId, return direct children only. Search recursively and retain the ancestor path of every match.
public synchronized List<NavigationTreeNodeDTO> query(
String parentId,
String query,
SparkPageRequestContext context) {
Map<String, NodeRecord> source = isDevelopmentPreview(context)
? designPreviewNodes : nodes;
String keyword = StringUtils.trimToNull(query);
if (keyword != null) {
return children(source, null).stream()
.map(root -> filterTree(source, root, keyword))
.filter(Objects::nonNull)
.toList();
}
return children(source, StringUtils.trimToNull(parentId)).stream()
.map(node -> toDto(source, node))
.toList();
}
private boolean isDevelopmentPreview(SparkPageRequestContext context) {
return context != null
&& context.getEnvironment() == SparkPageEnvironment.DEV
&& context.getRenderMode() == SparkPageRenderMode.DESIGN_PREVIEW;
}
Tree02 Test therefore reads stable designPreviewNodes. Runtime requests use the repository selected for DEV, TEST, or PROD. Do not infer this state from custom query parameters when the standard headers already carry it.
12.4 Create, update, and delete
Validate identifiers, parent nodes, labels, permissions, and delete rules on the server. This sample permits deletion only for leaf nodes. User-facing failures use LatticeRuntimeException(Message.code(...)); keep translated messages in server-side i18n resources instead of composing strings in the service.
public synchronized NavigationTreeNodeDTO delete(String nodeId) {
String id = StringUtils.trimToNull(nodeId);
NodeRecord node = nodes.get(id);
if (node == null || !children(nodes, id).isEmpty()) {
throw new LatticeRuntimeException(
Message.code("SPARK-SAMPLE-NAVIGATION-0008"));
}
nodes.remove(id);
return toDto(nodes, node);
}
12.5 Persist a drag
Never trust the submitted position directly. Verify that both nodes exist, reject self-drops and descendant cycles, derive the new parent, and update sibling order in one transaction. Add permission and optimistic-lock checks for production data.
String newParentId = StringUtils.equals(position, "INSIDE")
? dropId : dropped.parentId;
if (!List.of("BEFORE", "INSIDE", "AFTER").contains(position)
|| isDescendant(dropId, dragId)) {
throw new LatticeRuntimeException(
Message.code("SPARK-SAMPLE-NAVIGATION-0006"));
}
// Rewrite the old and new sibling order in the same transaction.
Return a non-success result when persistence fails so Tree can roll back the visual move.
12.6 Filter the content component
The ProTable request adds nodeId to the regular product query. Category nodes continue to use category; business nodes can be mapped on the server.
private void applyBusinessNodeFilter(NavigationProductQueryRequest query) {
String nodeId = StringUtils.lowerCase(
StringUtils.trimToEmpty(query.getNodeId()), Locale.ROOT);
switch (nodeId) {
case "operations" -> query.setStatus("DRAFT");
case "support" -> query.setStatus("DISCONTINUED");
case "enterprise" -> query.setMinPrice(BigDecimal.valueOf(1000));
default -> {
// Category nodes continue to use the category returned by Tree.
}
}
}
The product controller should resolve the same standard page request headers before calling the service, so preview and runtime queries follow the same environment contract.
13. Validate and troubleshoot
Validate one layer at a time: data loading, selection, node commands, and finally drag persistence.
- Click Test and confirm two root nodes are parsed.
- Expand Product Catalog and confirm only its direct children are requested.
- Search for
tier 2and confirm ancestor paths remain visible. - Select several category and business nodes and confirm the right-side list changes.
- Test More and right-click commands without accidentally firing Tree
onClick. - Complete View, Edit, Add Child, and Delete, then confirm Tree reloads.
- Move Accessories before Computing, refresh, and confirm the order persists.
- Simulate a failed reorder request and confirm the visual move rolls back.
| Symptom | Check first |
|---|---|
| Test request fails | URL, method, connection, authentication, and CORS |
| HTTP 200 but zero nodes | Whether Response List Path points to an array |
| Counts appear but titles do not | Title Field and Value Field names |
| A directory has no expand control | Whether Leaf Field incorrectly returns true |
| Expanding does not load children | Child Nodes URL, Parent Parameter, and parent Value |
| Search returns isolated leaves | Whether the backend includes ancestor paths |
| Different nodes show the same list | Page Context order and ProTable Query Context paths |
| Menu items are absent | Trigger, Hide This Action, and current node data |
| Order resets after refresh | onDrop parameters and backend persistence |
| A failed save does not roll back | Whether Send Request returns a failure/interruption result |