Xây dựng menu đa cấp với ASP.NET Core MVC

Một số bạn hỏi mình về cách xây dựng menu đa cấp trong ASP.NET MVC, vấn đề này cũng khá hay vì nhiều trường hợp ví dụ danh mục sản phẩm hay tin tức cần nhiều cấp thay vì 2 cấp, nếu giới hạn số cấp thì sẽ khó trong việc hiển thị dữ liệu đa cấp.

Để xây dựng menu đa cấp, các bạn cần có database dạng đa cấp với các trường sau, ví dụ ở đây mình sẽ tạo một bảng tên là Categories:

Tên trường Kiểu dữ liệu
Id int NOT NULL
Name nvarchar(200)
ParentId int NULL

Ở đây trường ParentId là trường chỉ ra khóa chính của bản ghi cha, ở cơ sở dữ liệu chúng ta lưu phẳng nhưng khi hiển thị lên chúng ta cần hiển thị dạng như thế này:

Dữ liệu của chúng ta trong database sẽ có dạng như thế này:

Id Name ParentId
1 Phim NULL
2 Phim hài 1
3 Phim hài bộ 2

Như vậy ở đây chúng ta có menu 3 cấp, trong đó Phim là cấp 1, Phim hài là cấp 2 và Phim hài bộ là cấp 3. Vậy các bạn đã hiểu cấu trúc dữ liệu rồi chứ? Ở đây chúng ta có thể lưu trữ bao nhiêu cấp tùy thích.

Bây giờ chúng ta sẽ cùng triển khai code sau khi có cơ sở dữ liệu như trên nhé, cách tạo bảng sử dụng Entity Framework Core mình sẽ không hướng dẫn ở đây mà chúng ta giả sử là đã tạo ra được bảng Categories và có dữ liệu như trên rồi. Các bạn có thể tham khảo khóa học: Xây dựng ứng dụng CMS với ASP.NET Core 8.0 + Angular

Bước 1: Chúng ta sẽ tạo ra 1 view model như sau để hứng dữ liệu từ Repository Layer, class này chứa một thuộc tính HasChildren để check xem nó có chứa bản ghi con nào không? Còn thuộc tính Children chứa danh sách bản ghi con.

  public class NavigationItemViewModel
  {
      public required string Id { get; set; }

      public string? ParentId { get; set; }

      public required string Name { get; set; }

      public required string Url { get; set; }

      public bool HasChildren
      { 
          get
          {
              return Children.Any();
          }
      }

      public IEnumerable<NavigationItemViewModel> Children { get; set; } = new List<NavigationItemViewModel>();
  }

Tiếp theo chúng ta tạo một service tên là INavigationService:

public interface INavigationService
{
    Task<IEnumerable<NavigationItemViewModel>> GetNavigationItemsAsync();
}

Và implement nó trong NavigationService, class này nhằm trả về một danh sách các NavigationItemViewModel đã được chuyển sạng dữ liệu phẳng sang dạng cha con theo Model ở trên:

public class NavigationService : INavigationService
{
    private readonly IUnitOfWork _unitOfWork;

    public NavigationService(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }

    public async Task<IEnumerable<NavigationItemViewModel>> GetNavigationItemsAsync()
    {
        var allCates = await _unitOfWork.MovieCategories.GetAllAsync();
        var allPostCates = await _unitOfWork.PostCategories.GetAllAsync();

        return BuildNavigation(allCates, allPostCates);
    }

    public IEnumerable<NavigationItemViewModel> BuildNavigation(IEnumerable<MovieCategory> allCates, IEnumerable<PostCategory> allPostCates)
    {
        if (allCates == null || !allCates.Any())
        {
            return new List<NavigationItemViewModel>();
        }

        var roots = allCates.Where(x => !x.ParentId.HasValue).OrderBy(x => x.DisplayOrder).ToList();
        if (!roots.Any())
        {
            return new List<NavigationItemViewModel>();
        }

        var listResult = new List<NavigationItemViewModel>();
        foreach (var root in roots)
        {
            var children = allCates.Where(x => x.ParentId.Equals(root.Id)).OrderBy(x => x.DisplayOrder).ToList();

            listResult.Add(new NavigationItemViewModel
            {
                Id = root.Id.ToString(),
                ParentId = root.ParentId?.ToString(),
                Name = root.Name,
                Url = string.Format(UrlConsts.MoviesByCategory, root.Slug),
                Children = BuildMovieChildren(allCates, children)
            });
        }

        return listResult;
    }

    private IEnumerable<NavigationItemViewModel> BuildMovieChildren(IEnumerable<MovieCategory> allCates, IEnumerable<MovieCategory> children)
    {
        if (children == null || !children.Any())
        {
            return new List<NavigationItemViewModel>();
        }

        var listResult = new List<NavigationItemViewModel>();
        foreach (var child in children)
        {
            var subChildren = allCates.Where(x => x.ParentId.Equals(child.Id)).OrderBy(x => x.DisplayOrder).ToList();

            listResult.Add(new NavigationItemViewModel
            {
                Id = child.Id.ToString(),
                ParentId = child.ParentId?.ToString(),
                Name = child.Name,
                Url = string.Format(UrlConsts.MoviesByCategory, child.Slug),
                Children = BuildMovieChildren(allCates, subChildren)
            });
        }

        return listResult;
    }

    private IEnumerable<NavigationItemViewModel> BuildPostChildren(IEnumerable<PostCategory> allCates, IEnumerable<PostCategory> children)
    {
        if (children == null || !children.Any())
        {
            return new List<NavigationItemViewModel>();
        }

        var listResult = new List<NavigationItemViewModel>();
        foreach (var child in children)
        {
            var subChildren = allCates.Where(x => x.ParentId.Equals(child.Id)).OrderBy(x => x.SortOrder).ToList();

            listResult.Add(new NavigationItemViewModel
            {
                Id = child.Id.ToString(),
                ParentId = child.ParentId?.ToString(),
                Name = child.Name,
                Url = string.Format(UrlConsts.PostByCategory, child.Slug),
                Children = BuildPostChildren(allCates, subChildren)
            });
        }

        return listResult;
    }
}

Khi đã có danh sách item theo dạng cha con, chúng ta cần hiển thị ra UI, ở đây mình dùng 1 cái ViewComponent tên là Navigation trong Controllers/Components/NavigationViewComponent.cs:

 public class NavigationViewComponent : ViewComponent
 {
     private readonly INavigationService _navigationService;
     public NavigationViewComponent(INavigationService navigationService)
     {
         _navigationService = navigationService;
     }

     public async Task<IViewComponentResult> InvokeAsync()
     {
         var navItems = await _navigationService.GetNavigationItemsAsync();
         return View(navItems);
     }
 }

Và hiển thị ở View trong Shared/Components/Navigation/Default.cshtml như sau:

@model IEnumerable<NavigationItemViewModel>
@foreach (var item in Model)
{
    <li class="header-menu-item">
        <a title="@item.Name" href="@item.Url" class="header-menu-link category"
           id="@string.Format("menu-{0}-item", item.Id)">
            <span>@item.Name</span>
            @if (item.HasChildren)
            {
                <i class="far fa-angle-down arrow" v-on:click.prevent.stop="onToggleSubmenu('@item.Id')"></i>
            }

        </a>
        @if (item.HasChildren)
        {
            <ul id="@string.Format("menu-{0}-submenu", item.Id)" class="submenu">
                @foreach (var child in item.Children)
                {
                    @await Html.PartialAsync("_SubMenuItem", child)
                }
            </ul>
        }
    </li>
}

Chúng ta cần tạo 1 Partial View tên là _SubMenuItem, đây là Partial view sẽ đệ quy khi một menu có danh sách menu con trong nó, đệ quy đến khi không có danh sách menu con trong nó nữa mới thôi. Nội dung file _SubMenuItem.cshtml nằm trong thư mục Shared:

@model NavigationItemViewModel;

<li>
    <a title="@Model.Name" href="@Model.Url"
       id="@string.Format("menu-{0}-item", Model.Id)">
        <span>@Model.Name</span>
        @if (Model.HasChildren)
        {
            <i class="far fa-angle-right arrow" v-on:click.prevent.stop="onToggleSubmenu('@Model.Id')"></i>
        }
    </a>

    @if (Model.HasChildren)
    {
        <ul id="@string.Format("menu-{0}-submenu", Model.Id)" class="submenu submenu-2">
            @foreach (var child in Model.Children)
            {
                @await Html.PartialAsync("_SubMenuItem", child)
            }
        </ul>
    }
</li>

Ở đây có một điều là, khi lặp đến một item trong danh sách NavigationItem thì cứ check nếu bản ghi hiện tại HasChildren = true thì sẽ tiếp tục gọi _SubMenuItem partial view và truyền bản ghi con của nó vào, cứ như vậy. Khi nào Model.HasChildren = false thì sẽ dừng lại.


Tác giả: Bạch Ngọc Toàn

Chú ý: Tất cả các bài viết trên TEDU.COM.VN đều thuộc bản quyền TEDU, yêu cầu dẫn nguồn khi trích lại trên website khác.

Lên trên