Note
We created this example only for demonstration purposes. We can't guarantee that it will work in all usage scenarios. If you need to add some functionality to it, feel free to do this on your own. Researching DevExpress source code will help you with this task. Also, refer to the following help topic for more information: Debug DevExpress .NET Source Code with Debug Symbols. Unfortunately extending this example with custom code is outside the scope of our Support Service: Technical Support Scope.
Implement the following components to add the TreeList Editor to your ASP.NET Core Blazor application:
- A Razor component based on the DevExtreme TreeList widget.
- A component model that changes the state of the component.
- A component renderer that binds the component model with the component.
- A List Editor that integrates the component into your XAF application.
The following image demonstrates the result:
Alternative Solutions
Group List View Data Using DxGridListEditor
If this DevExtreme-based solution does not meet your requirements (for instance, you do not want to implement the ITreeNode contract in your data model from scratch or do not want to inherit your class from our HCategory), you can consider using the data grid and group by the 'Parent' property to emulate a tree: Group List View Data. In this case, it is also natural to enable a 'Split View' (MasterDetailMode = ListAndDetailView): Enable Split Layout in a List View.
Implement a Custom List Editor or View Item from Scratch
Of course, you can also build your own List Editor or View Item to display your custom data model using suitable DevExtreme or Blazor components: Using a Custom Control that is not Integrated by Default.
Implementation Details
ITreeNode-based Data Model
XAF has a built-in ITreeNode interface, which can be implemented by business objects to visualize them as a tree in a ListView (refer to the Category and related classes in this example). You can find more example code of ITreeNode-based data models below OR you can inherit your business class from our built-in HCategory class):
- EF Core: "c:\Program Files\DevExpress 2X.Y\Components\Sources\DevExpress.Persistent\DevExpress.Persistent.BaseImpl.EFCore\HCategory.cs"
- XPO: "c:\Program Files\DevExpress 2X.Y\Components\Sources\DevExpress.Persistent\DevExpress.Persistent.BaseImpl.Xpo\HCategory.cs"
This hierarchical data visualization is currently supported for WinForms and ASP.NET WebForms out-of-the-box (hence this example for ASP.NET Core Blazor).
Razor Component
- Create a Razor class library (RCL) project (BlazorComponents). Reference it in your TreeListDemoEF.Blazor.Server project.
- Register DevExtreme libraries in the TreeListDemoEF.Blazor.Server/Pages/_Host.cshtml page as described in the following topic: Add DevExtreme to a jQuery Application.
- Add the TreeList.razor Razor component to the BlazorComponents project.
The following table describes the APIs implemented in this component:API Type Description GetDataAsync parameter Encapsulates a method that fetches data on demand. FieldNames parameter Stores an array of field names. GetFieldDisplayText parameter Encapsulates a method that returns field captions. GetKey parameter Encapsulates a method that returns the current key value. HasChildren parameter Encapsulates a method that determines whether the currently processed node has child nodes. RowClick parameter Encapsulates a method that handles an event when users click a row. SelectionChanged parameter Encapsulates a method that handles an event when users change selection. OnRowClick and OnSelectionChanged methods Used to raise the RowClick and SelectionChanged events. OnAfterRenderAsync method Initializes the necessary IJSObjectReference, ElementReference, and DotNetObjectReference fields for interaction with the DevExtreme TreeList widget. OnGetDataAsync method Creates a dictionary of field name-value pairs. This method is called from JavaScript code to fetch data based on the passed parent key value. Refresh method Calls the JavaScript TreeList.refresh method. - Add the treeListModule.js script with the TreeList API to the BlazorComponents\wwwroot folder. In the script, configure TreeList to load data on demand as described in the following article: Load Data on Demand. Use the DotNetObjectReference object to call the declared .NET OnGetDataAsync method and fetch data. Handle the TreeList.rowClick and TreeList.selectionChanged events to call the declared .NET OnRowClick and OnSelectionChanged methods.
See also:
- Call .NET methods from JavaScript functions in ASP.NET Core Blazor
- Call JavaScript functions from .NET methods in ASP.NET Core Blazor
- Tree List > Data Binding > Load Data on Demand
Component Model
- In the Blazor project (TreeListDemoEF.Blazor.Server), create the ComponentModelBase descendant and name it TreeListModel.cs.
The following table describes the APIs implemented in this component:API Type Description GetDataAsync property Encapsulates a method that fetches data on demand. FieldNames property Stores an array of field names. GetFieldDisplayText property Encapsulates a method that returns field captions. GetKey property Encapsulates a method that returns the current key value. HasChildren property Encapsulates a method that determines whether the currently processed node has child nodes. RowClick, SelectionChanged, RefreshRequested events Occur when users click a row and change selection. OnRowClick, OnSelectionChanged, Refresh methods Used to raise the corresponding events. - Create EventArgs descendants to pass key values to the RowClick and SelectionChanged event handlers. See these classes in the following file: TreeListModel.cs.
Component Renderer
- In the Blazor project (TreeListDemoEF.Blazor.Server), create a new Razor component and name it TreeListRenderer.razor. This component renders the TreeList component from the RCL project.
- Ensure that the component’s Build Action property is set to Content.
- Declare the required parameters and implement the IDisposable interface.
List Editor
- Create a ListEditor descendant, apply the ListEditorAttribute to this class, and pass an ITreeNode type as a parameter.
- Implement the IComplexListEditor interface. In the IComplexListEditor.Setup method, initialize an Object Space instance.
The following table describes the API implemented in this List Editor:
API | Type | Description |
---|---|---|
SelectionType | property | Returns SelectionType.Full. This setting allows users to open the Detail View by click. |
CreateControlsCore | method | Returns an instance of the TreeList component. |
AssignDataSourceToControl | method | Assigns the List Editor’s data source to the component model. If the data source implements the IBindingList interface, this method handles data change notifications. |
OnControlsCreated | method | Passes methods to the created delegates, initializes the arrays of field names, and subscribes to the component model’s RowClick and SelectionChanged events. |
BreakLinksToControls | method | Unsubscribes from the component model’s events and resets its data to release resources. |
Refresh | method | Calls the component model's Refresh method to update the List Editor layout when its data is changed. |
GetSelectedObjects | method | Returns an array of selected objects. |
Supported Data Access mode
As the created tree list editor supports only the 'Client' Data Access mode, you need to use the RegisterEditorSupportedModes method as shown here:
Files to Review
- BlazorComponents/TreeList.razor
- BlazorComponents/wwwroot/treeListModule.js
- EFCore/XAFTreeList.Module.Blazor/Editors/TreeListModel.cs
- EFCore/XAFTreeList.Module.Blazor/Editors/TreeListRenderer.razor
- EFCore/XAFTreeList.Module.Blazor/Editors/TreeListEditor.cs
Documentation
- How to: Use a Custom Component to Implement List Editor (Blazor)
- Using a Custom Control that is not Integrated by Default
More Examples
Does this example address your development requirements/objectives?
(you will be redirected to DevExpress.com to submit your response)
Example Code
Razor@page "/"
@namespace TreeListDemoEF.Blazor.Server
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@using DevExpress.ExpressApp.Blazor.Components
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, shrink-to-fit=no" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<!-- meta name="theme-color" content="#000000" -->
<title>TreeListDemoEF</title>
<base href="~/" />
<script type="text/javascript" src="https://ajax.aspnetcdn.com/ajax/jquery/jquery-3.4.1.min.js"></script>
<link rel="stylesheet" href="https://cdn3.devexpress.com/jslib/22.2.5/css/dx.common.css">
<link rel="stylesheet" href="https://cdn3.devexpress.com/jslib/22.2.5/css/dx.light.css">
<script type="text/javascript" src="https://cdn3.devexpress.com/jslib/22.2.5/js/dx.all.js"></script>
<component type="typeof(BootstrapThemeLink)" render-mode="Static" />
</head>
<body>
@{
string userAgent = Request.Headers["User-Agent"];
bool isIE = userAgent.Contains("MSIE") || userAgent.Contains("Trident");
}
@if(isIE) {
<link href="css/site.css" rel="stylesheet" />
<div class="d-flex flex-column justify-content-center align-items-center h-100">
<div class="d-flex">
<img class="mt-2 mr-4" src="_content/DevExpress.ExpressApp.Blazor/images/Sad.svg" width="60" height="60" />
<div>
<div class="h1">Internet Explorer is not supported.</div>
<p style="font-size: 1rem; opacity: 0.75;" class="m-0">TreeListDemoEF cannot be loaded in Internet Explorer.<br>Please use a different browser.</p>
</div>
</div>
</div>
}
else {
<component type="typeof(SplashScreen)" render-mode="Static" param-Caption='"TreeListDemoEF"' param-ImagePath='"images/SplashScreen.svg"' />
<link href="_content/DevExpress.ExpressApp.Blazor/styles.css" rel="stylesheet" />
<link href="css/site.css" rel="stylesheet" />
<script src="_content/DevExpress.ExpressApp.Blazor/scripts.js"></script>
<app class="d-none">
<component type="typeof(App)" render-mode="Server" />
</app>
<component type="typeof(AlertsHandler)" render-mode="Server" />
<div id="blazor-error-ui">
<component type="typeof(BlazorError)" render-mode="Static" />
</div>
<script src="_framework/blazor.server.js"></script>
}
</body>
</html>
Razor@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@implements IAsyncDisposable
@inject IJSRuntime JSRuntime
<div @ref="treeListElement"></div>
@code {
private ElementReference treeListElement;
private DotNetObjectReference<TreeList> dotNetHelper;
private IJSObjectReference treeListModule;
private IJSObjectReference treeList;
[Parameter]
public Func<string, Task<IEnumerable<object>>> GetDataAsync { get; set; }
[Parameter]
public string[] FieldNames { get; set; }
[Parameter]
public Func<object, string, string> GetFieldDisplayText { get; set; }
[Parameter]
public Func<object, string> GetKey { get; set; }
[Parameter]
public Func<object, bool> HasChildren { get; set; }
[Parameter]
public EventCallback<string> RowClick { get; set; }
[Parameter]
public EventCallback<string[]> SelectionChanged { get; set; }
protected override void OnParametersSet()
{
base.OnParametersSet();
List<string> missingParameters = new List<string>();
if (GetDataAsync is null) missingParameters.Add(nameof(GetDataAsync));
if (FieldNames is null) missingParameters.Add(nameof(FieldNames));
if (GetFieldDisplayText is null) missingParameters.Add(nameof(GetFieldDisplayText));
if (GetKey is null) missingParameters.Add(nameof(GetKey));
if (HasChildren is null) missingParameters.Add(nameof(HasChildren));
if (missingParameters.Count > 0)
{
throw new ArgumentException($"Please declare the following parameter(s): {string.Join(',', missingParameters)}");
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
dotNetHelper = DotNetObjectReference.Create(this);
treeListModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./_content/BlazorComponents/treeListModule.js");
treeList = await treeListModule.InvokeAsync<IJSObjectReference>("addTreeListToElement", treeListElement, FieldNames, dotNetHelper);
}
}
protected override bool ShouldRender() => false;
public async Task Refresh() => await treeListModule.InvokeVoidAsync("refresh", treeListElement);
[JSInvokable]
public async Task<List<Dictionary<string, object>>> OnGetDataAsync(string parentKey)
{
List<Dictionary<string, object>> dictionaries = new List<Dictionary<string, object>>();
var data = await GetDataAsync(parentKey);
foreach (var item in data)
{
var dictionary = FieldNames.ToDictionary(field => field, field => (object)GetFieldDisplayText(item, field));
dictionary.Add("__parentKey", parentKey);
dictionary.Add("__key", GetKey(item));
dictionary.Add("__hasChildren", HasChildren(item));
dictionaries.Add(dictionary);
}
return dictionaries;
}
[JSInvokable]
public async Task OnRowClick(string key) => await RowClick.InvokeAsync(key);
[JSInvokable]
public async Task OnSelectionChanged(string[] keys) => await SelectionChanged.InvokeAsync(keys);
async ValueTask IAsyncDisposable.DisposeAsync()
{
try
{
if (treeListModule is not null) await treeListModule.InvokeVoidAsync("dispose", treeListElement);
}
catch (Exception ex) when (ex.GetType().Name == "JSDisconnectedException")
{
//https://github.com/dotnet/aspnetcore/issues/33336#issuecomment-862425579
}
if (treeList is not null) await treeList.DisposeAsync();
if (treeListModule is not null) await treeListModule.DisposeAsync();
dotNetHelper?.Dispose();
}
}
JavaScriptexport function addTreeListToElement(element, fieldNames, dotNetHelper) {
const keyExpression = "__key";
const parentKeyExpression = "__parentKey";
const hasChildrenExpression = "__hasChildren";
var treeList = $(element).dxTreeList({
keyExpr: keyExpression,
rootValue: null,
parentIdExpr: parentKeyExpression,
hasItemsExpr: hasChildrenExpression,
columns: fieldNames,
dataSource: {
key: keyExpression,
load: function (options) {
var parentKeys = null;
if (options.parentIds) {
parentKeys = options.parentIds[0];
}
return dotNetHelper.invokeMethodAsync('OnGetDataAsync', parentKeys);
}
},
remoteOperations: {
filtering: true
},
selection: {
mode: "multiple"
},
onRowClick: function (e) {
if (!e.event.target.parentElement.classList.contains("dx-treelist-expanded") && !e.event.target.parentElement.classList.contains("dx-treelist-collapsed")) {
dotNetHelper.invokeMethodAsync('OnRowClick', e.key);
}
},
onSelectionChanged: function (e) {
dotNetHelper.invokeMethodAsync('OnSelectionChanged', e.selectedRowKeys);
},
columnAutoWidth: true,
wordWrapEnabled: true,
showRowLines: true,
showBorders: true
}).dxTreeList('instance');
return treeList;
}
export function refresh(element) {
$(element).dxTreeList('instance').option("expandedRowKeys",[]);
$(element).dxTreeList('instance').refresh();
}
export function dispose(element) {
if (element) {
$(element).dxTreeList('dispose');
}
}
C#using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using DevExpress.ExpressApp.Blazor.Components.Models;
namespace TreeListDemoEF.Blazor.Server.Editors.TreeListEditor {
public class TreeListModel : ComponentModelBase {
public Func<string, Task<IEnumerable<object>>> GetDataAsync {
get => GetPropertyValue<Func<string, Task<IEnumerable<object>>>>();
set => SetPropertyValue(value);
}
public string[] FieldNames {
get => GetPropertyValue<string[]>();
set => SetPropertyValue(value);
}
public Func<object, string, string> GetFieldDisplayText {
get => GetPropertyValue<Func<object, string, string>>();
set => SetPropertyValue(value);
}
public Func<object, string> GetKey {
get => GetPropertyValue<Func<object, string>>();
set => SetPropertyValue(value);
}
public Func<object, bool> HasChildren {
get => GetPropertyValue<Func<object, bool>>();
set => SetPropertyValue(value);
}
public void Refresh() => RefreshRequested?.Invoke(this, EventArgs.Empty);
public void OnRowClick(string key) => RowClick?.Invoke(this, new TreeListRowClickEventArgs(key));
public void OnSelectionChanged(string[] keys) => SelectionChanged?.Invoke(this, new TreeListSelectionChangedEventArgs(keys));
public event EventHandler RefreshRequested;
public event EventHandler<TreeListRowClickEventArgs> RowClick;
public event EventHandler<TreeListSelectionChangedEventArgs> SelectionChanged;
}
public class TreeListRowClickEventArgs : EventArgs {
public TreeListRowClickEventArgs(string key) {
Key = key;
}
public string Key { get; }
}
public class TreeListSelectionChangedEventArgs : EventArgs {
public TreeListSelectionChangedEventArgs(string[] keys) {
Keys = keys;
}
public string[] Keys { get; }
}
}
Razor@using BlazorComponents
@implements IDisposable
<TreeList @ref="treeList"
@key="@ComponentModel"
GetDataAsync="@ComponentModel.GetDataAsync"
FieldNames="@ComponentModel.FieldNames"
GetFieldDisplayText="@ComponentModel.GetFieldDisplayText"
GetKey="@ComponentModel.GetKey"
HasChildren="@ComponentModel.HasChildren"
RowClick="@ComponentModel.OnRowClick"
SelectionChanged="@ComponentModel.OnSelectionChanged">
</TreeList>
@code {
public static RenderFragment Create(TreeListModel componentModel) => @<TreeListRenderer ComponentModel=@componentModel />;
private TreeList treeList;
[Parameter]
public TreeListModel ComponentModel { get; set; }
public override Task SetParametersAsync(ParameterView parameters)
{
if (ComponentModel is not null)
ComponentModel.RefreshRequested -= ComponentModel_RefreshRequested;
return base.SetParametersAsync(parameters);
}
protected override void OnParametersSet()
{
base.OnParametersSet();
ComponentModel.RefreshRequested += ComponentModel_RefreshRequested;
}
protected void ComponentModel_RefreshRequested(object sender, EventArgs e) => treeList?.Refresh();
void IDisposable.Dispose()
{
ComponentModel.RefreshRequested -= ComponentModel_RefreshRequested;
}
}
C#using DevExpress.ExpressApp;
using DevExpress.ExpressApp.Blazor;
using DevExpress.ExpressApp.Blazor.Components;
using DevExpress.ExpressApp.Editors;
using DevExpress.ExpressApp.Model;
using DevExpress.Persistent.Base.General;
using Microsoft.AspNetCore.Components;
using System.Collections;
using System.ComponentModel;
namespace TreeListDemoEF.Blazor.Server.Editors.TreeListEditor {
[ListEditor(typeof(ITreeNode))]
public class TreeListEditor : ListEditor, IComplexListEditor {
public class TreeListHolder : IComponentContentHolder {
private RenderFragment componentContent;
public TreeListHolder(TreeListModel componentModel) {
ComponentModel = componentModel ?? throw new ArgumentNullException(nameof(componentModel));
}
private RenderFragment CreateComponent() => ComponentModelObserver.Create(ComponentModel, TreeListRenderer.Create(ComponentModel));
public TreeListModel ComponentModel { get; }
RenderFragment IComponentContentHolder.ComponentContent => componentContent ??= CreateComponent();
}
private ITreeNode[] selectedObjects = Array.Empty<ITreeNode>();
private IObjectSpace objectSpace;
private IEnumerable<object> data;
public TreeListEditor(IModelListView model) : base(model) { }
void IComplexListEditor.Setup(CollectionSourceBase collectionSource, XafApplication application) {
objectSpace = collectionSource.ObjectSpace;
}
protected override object CreateControlsCore() => new TreeListHolder(new TreeListModel());
protected override void AssignDataSourceToControl(object dataSource) {
if (Control is TreeListHolder holder) {
if (data is IBindingList bindingList) {
bindingList.ListChanged -= BindingList_ListChanged;
}
data = (dataSource as IEnumerable)?.Cast<ITreeNode>();
if (dataSource is IBindingList newBindingList) {
newBindingList.ListChanged += BindingList_ListChanged;
}
}
}
protected override void OnControlsCreated() {
if (Control is TreeListHolder holder) {
holder.ComponentModel.GetDataAsync = GetDataAsync;
holder.ComponentModel.FieldNames = new string[] { nameof(ITreeNode.Name) };
holder.ComponentModel.GetFieldDisplayText = GetFieldDisplayText;
holder.ComponentModel.GetKey = GetKey;
holder.ComponentModel.HasChildren = HasChildren;
holder.ComponentModel.RowClick += ComponentModel_RowClick;
holder.ComponentModel.SelectionChanged += ComponentModel_SelectionChanged;
}
base.OnControlsCreated();
}
private Task<IEnumerable<object>> GetDataAsync(string parentKey) {
bool IsRoot(object obj) {
return obj is ITreeNode node && (node.Parent == null || !data.Contains(node.Parent));
}
if (parentKey is null) {
IEnumerable<object> rootData = data?.Where(n => IsRoot(n)) ?? Array.Empty<object>();
return Task.FromResult(rootData);
}
ITreeNode parent = GetNode(parentKey);
return Task.FromResult(parent.Children.Cast<object>());
}
private string GetFieldDisplayText(object item, string field) => ObjectTypeInfo.FindMember(field).GetValue(item)?.ToString();
private string GetKey(object item) => objectSpace.GetObjectHandle(item);
private ITreeNode GetNode(string key) => (ITreeNode)objectSpace.GetObjectByHandle(key);
private bool HasChildren(object item) => ((ITreeNode)item).Children.Count > 0;
public override void BreakLinksToControls() {
if (Control is TreeListHolder holder) {
holder.ComponentModel.RowClick -= ComponentModel_RowClick;
holder.ComponentModel.SelectionChanged -= ComponentModel_SelectionChanged;
}
AssignDataSourceToControl(null);
base.BreakLinksToControls();
}
public override void Refresh() {
if (Control is TreeListHolder holder) {
holder.ComponentModel.Refresh();
}
}
private void BindingList_ListChanged(object sender, ListChangedEventArgs e) {
Refresh();
}
private void ComponentModel_RowClick(object sender, TreeListRowClickEventArgs e) {
selectedObjects = new ITreeNode[] { GetNode(e.Key) };
OnSelectionChanged();
OnProcessSelectedItem();
}
private void ComponentModel_SelectionChanged(object sender, TreeListSelectionChangedEventArgs e) {
var items = e.Keys.Select(key => GetNode(key)).ToArray();
selectedObjects = items;
OnSelectionChanged();
}
public override SelectionType SelectionType => SelectionType.Full;
public override IList GetSelectedObjects() => selectedObjects;
}
}