C# cơ bản .NET Core

Bài thực hành này tiếp tục trên ví dụ cũ mvcblog: Xây dựng ứng dụng mẫu - Tích hợp HTML Editor Summernote, tải mã nguồn về mở ra tiếp tục phát triển ex068-summernote

Xây dựng Model bài Post

Model biểu diễn bài Post, mỗi bài Post đó có thể nằm trong một hoặc nhiều Category như sau:

Models/PostBase.cs

public class PostBase
{
    [Key]
    public int PostId {set; get;}

    [Required(ErrorMessage = "Phải có tiêu đề bài viết")]
    [Display(Name = "Tiêu đề")]
    [StringLength(160, MinimumLength = 5, ErrorMessage = "{0} dài {1} đến {2}")]
    public string Title {set; get;}

    [Display(Name = "Mô tả ngắn")]
    public string Description {set; get;}

    [Display(Name="Chuỗi định danh (url)", Prompt = "Nhập hoặc để trống tự phát sinh theo Title")]
    [Required(ErrorMessage = "Phải thiết lập chuỗi URL")]
    [StringLength(160, MinimumLength = 5, ErrorMessage = "{0} dài {1} đến {2}")]
    [RegularExpression(@"^[a-z0-9-]*$", ErrorMessage = "Chỉ dùng các ký tự [a-z0-9-]")]
    public string Slug {set; get;}

    [Display(Name = "Nội dung")]
    public string Content {set; get;}

    [Display(Name = "Xuất bản")]
    public bool Published {set; get;}

    public List<PostCategory>  PostCategories { get; set; }

}

Models/Post.cs

[Table("Post")]
public class Post : PostBase
{

    [Required]
    [Display(Name = "Tác giả")]
    public string AuthorId {set; get;}
    [ForeignKey("AuthorId")]
    [Display(Name = "Tác giả")]
    public AppUser Author {set; get;}

    [Display(Name = "Ngày tạo")]
    public DateTime DateCreated {set; get;}

    [Display(Name = "Ngày cập nhật")]
    public DateTime DateUpdated {set; get;}
}

Để tạo ra quan hệ nhiều - nhiều giữa Post và Category thực hiện tạo ra bảng PostCategory với Model như sau:

public class PostCategory
{
    public int PostID {set; get;}

    public int CategoryID {set; get;}

    [ForeignKey("PostID")]
    public Post Post {set; get;}

    [ForeignKey("CategoryID")]
    public Category Category {set; get;}
}

Cập nhật vào AppDbContext

public class AppDbContext : IdentityDbContext<AppUser> {

    public DbSet<Category> Categories {set; get;}
    public DbSet<Post> Posts {set; get;}
    public DbSet<PostCategory> PostCategories {set; get;}

    public AppDbContext (DbContextOptions<AppDbContext> options) : base (options) { }

    protected override void OnModelCreating (ModelBuilder builder) {

        // ...

        // Tạo key của bảng là sự kết hợp PostID, CategoryID, qua đó
        // tạo quan hệ many to many giữa Post và Category
        builder.Entity<PostCategory>().HasKey(p => new {p.PostID, p.CategoryID});

    }

}

Thực hiện lệnh tạo Migration và cập nhật database

dotnet ef migrations add AddPost
dotnet ef database update AddPost

Chú ý, nếu muốn hủy cập nhật thì thực hiện, cập nhật về phiên bản Migration trước (ví dụ AddCategory), sau đó xóa bản Migration hiện tại

dotnet ef database update AddCategory
dotnet ef migrations remove

Tạo các Controller Post với các chức năng CRUD

Tạo Controller tên PostController, với các Action và View mặc định, để từ đó sửa các chức năng

dotnet aspnet-codegenerator controller -name PostController -m mvcblog.Models.Post -dc mvcblog.Data.AppDbContext -outDir  Areas/Admin/Controllers -l _Layout

Sau lệnh này nó tạo ra Controller PostController tại Areas/Admin/Controllers/PostController.cs, và các file View tại thư mục Areas/Admin/Views/Post

Bạn hãy thêm thuộc tính [Authorize] cho Controller, đăng nhập và kiểm tra tại địa chỉ /admin/post

Do code phát sinh mặc định chưa đảm bảo hoạt động chính xác trên dữ liệu phức tạp mong muốn, giờ tiến hành cập nhật tùy biến từng chức năng về quản lý các bài viết Create, Edit, Update ...

Inject dịch vụ UserManager

Do trong quá trình tạo bài viết (post), chỉnh sửa, cập nhật ... cần có thông tin về User, nên ta tiến hành Inject dịch vụ UserManager vào controller bằng cách sửa

public class PostController : Controller
{
    private readonly AppDbContext _context;

    private readonly UserManager<AppUser> _usermanager;

    public PostController(AppDbContext context, UserManager<AppUser> usermanager)
    {
        _context = context;
        _usermanager = usermanager;
    }
    ...

Theo cách tương tự bạn có thể Inject dịch vụ Logger ILogger<PostController>

Tùy biến Action - Create tạo các bài Post

Cập nhật Action Create (cho get và post) như sau:

    [Area ("Admin")]
    [Authorize]
    public class PostController : Controller {

        /...

        [BindProperty]
        public int[] selectedCategories { set; get; }

        // GET: Admin/Post/Create
        public async Task Create () {

            // Thông tin về User tạo Post
            var user = await _usermanager.GetUserAsync (User);
            ViewData["userpost"] = $"{user.UserName} {user.FullName}";

            // Danh mục chọn để đăng bài Post
            var categories = await _context.Categories.ToListAsync ();
            ViewData["categories"] = new MultiSelectList (categories, "Id", "Title");
            return View ();

        }


        // POST: Admin/Post/Create
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task Create ([Bind ("PostId,Title,Description,Slug,Content,Published")] PostBase post) {

            var user = await _usermanager.GetUserAsync (User);
            ViewData["userpost"] = $"{user.UserName} {user.FullName}";

            // Phát sinh Slug theo Title
            if (ModelState["Slug"].ValidationState == ModelValidationState.Invalid) {
                post.Slug = Utils.GenerateSlug (post.Title);
                ModelState.SetModelValue ("Slug", new ValueProviderResult (post.Slug));
                // Thiết lập và kiểm tra lại Model
                ModelState.Clear();
                TryValidateModel (post);
            }


            if (selectedCategories.Length == 0) {
                ModelState.AddModelError (String.Empty, "Phải ít nhất một chuyên mục");
            }

            bool SlugExisted = await _context.Posts.Where(p => p.Slug == post.Slug).AnyAsync();
            if (SlugExisted)
            {
                ModelState.AddModelError(nameof(post.Slug), "Slug đã có trong Database");
            }

            if (ModelState.IsValid) {
                //Tạo Post
                var newpost = new Post () {
                    AuthorId = user.Id,
                    Title = post.Title,
                    Slug = post.Slug,
                    Content = post.Content,
                    Description = post.Description,
                    Published = post.Published,
                    DateCreated = DateTime.Now,
                    DateUpdated = DateTime.Now
                };
                _context.Add (newpost);
                await _context.SaveChangesAsync ();

                // Chèn thông tin về PostCategory của bài Post
                foreach (var selectedCategory in selectedCategories) {
                    _context.Add (new PostCategory () { PostID = newpost.PostId, CategoryID = selectedCategory });
                }
                await _context.SaveChangesAsync ();

                return RedirectToAction (nameof (Index));
            }

            var categories = await _context.Categories.ToListAsync ();
            ViewData["categories"] = new MultiSelectList (categories, "Id", "Title", selectedCategories);
            return View (post);
        }

      /..
}

Cả hai Action này sử dụng View - Create.cshtml có nội dung

@model mvcblog.Models.PostBase

@{
    ViewData["Title"] = "Tạo mới bài viết";
    Layout = "_Layout";
}

<h1>Tạo bài viết</h1>
<hr />
<div class="row">
    <div class="col-md-8">
        <form asp-action="Create">
            <div asp-validation-summary="All" class="text-danger"></div>


            <div class="form-group">
                <label class="control-label">Chọn danh mục</label>
                @Html.ListBox("selectedCategories", ViewBag.categories, 
                    new {@class="w-100", id = "selectedCategories"})
            </div>


            <div class="form-group">
                <label asp-for="Title" class="control-label"></label>
                <input asp-for="Title" class="form-control" />
                <span asp-validation-for="Title" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Description" class="control-label"></label>
                <textarea asp-for="Description" class="form-control"></textarea>
                <span asp-validation-for="Description" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Slug" class="control-label"></label>
                <input asp-for="Slug" class="form-control" />
                <span asp-validation-for="Slug" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Content" class="control-label"></label>
                <textarea asp-for="Content" class="form-control"></textarea>
                <span asp-validation-for="Content" class="text-danger"></span>
            </div>
            <div class="form-group form-check">
                <label class="form-check-label">
                    <input class="form-check-input" asp-for="Published" /> 
                    @Html.DisplayNameFor(model => model.Published)
                </label>
            </div>
            <div class="form-group">Tác giả: <strong>@ViewBag.userpost</strong></div>
            <div class="form-group">
                <input type="submit" value="Tạo mới" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

<div>
    <a asp-action="Index">Quay lại danh sách</a>
</div>


@section Scripts
{
    @await Html.PartialAsync("_Summernote", new {height = 200, selector = "#Content"})
    
    <script src="~/lib/multiple-select/multiple-select.min.js"></script>
    <link rel="stylesheet" href="~/lib/multiple-select/multiple-select.min.css" />
    <script>
          $('#selectedCategories').multipleSelect({
                selectAll: false,
                keepOpen: false,
                isOpen: false
            });
    </script>

}

Một số lưu ý về code sử dụng trong chức năng tạo bài viết ở trên

Mỗi bài Post có thể nằm trong một hoặc vài Category, bảng PostCategory có mối quan hệ nhiều nhiều. Bảng trung gian đó là PostCategory với hai trường dữ liệu PostIDCategoryID

Trong trang tạo có cho phép lựa chọn các Category của Post.

mvcblog

Để thực hiện chức năng này - có dùng thuộc tính là mảng chứa các CategoryID của bài Post

[BindProperty]
public int[] selectedCategories { set; get; }

Để tạo phần tử HTML chọn các Category sẽ sử dụng MultiSelectList và gửi nó đến View

var categories = await _context.Categories.ToListAsync ();
ViewData["categories"] = new MultiSelectList (categories, "Id", "Title");

Tại View dựng phần tử Select bằng

<div class="form-group">
    <label class="control-label">Chọn danh mục</label>
    @Html.ListBox("selectedCategories", ViewBag.categories, 
        new {@class="w-100", id = "selectedCategories"})
</div>

Phần tử HTML này có tên selectedCategories bind với Controller. Có thể tích hợp thư viện JS - multiple-select (Xem phần cuối Role trong Identity) Bạn cần tải về thư viện JS tại multiple-select, sau đó ở Create.cshtml, phần cuối có section tích hợp thư viện và kích hoạt nó cho phần tử selectedCategories

@section Scripts
{
    @await Html.PartialAsync("_Summernote", new {height = 200, selector = "#Content"})
    <script src="~/lib/multiple-select/multiple-select.min.js"></script>
    <link rel="stylesheet" href="~/lib/multiple-select/multiple-select.min.css" />
    <script>
          $('#selectedCategories').multipleSelect({
              selectAll: false,
              keepOpen: false,
              isOpen: false
          });
    </script>

}

Trong đoạn section trên cũng kích hoạt Summernote cho phần tử Content để soạn thảo HTML (xem Tích hợp Summernote vào ASP.NET )

Trong dữ liệu Post có trường Slug, có thể dùng như là một định danh đến bài viết - sau này có thể dùng để tạo Url, khi tạo nếu không nhập Slug thì nó sinh ra từ Title (ví dụ nếu Title là "Bài viết" thì phát sinh Slug là "bai-viet", đoạn code thực hiện chức năng đó là:

// Phát sinh Slug theo Title nếu Slug không được nhập
if (ModelState["Slug"].ValidationState == ModelValidationState.Invalid) {
    post.Slug = Utils.GenerateSlug (post.Title);
    ModelState.SetModelValue ("Slug", new ValueProviderResult (post.Slug));
    // Thiết lập và kiểm tra lại Model
    ModelState.Clear();
    TryValidateModel (post);
}

Utils.GenerateSlug là phương thức tĩnh, chuyển đổi Title thành Url thân thiện, phương thức này được xây dựng như sau: mã nguồn Utils.GenerateSlug

Như vậy đã hoàn thành chức năng tạo bài viết, hãy tạo một số bài viết mẫu của bạn

mvcblog

Tùy biến Index - Trang danh sách bài viết

mvcblog

Action Index trong Controller được sửa lại như sau:

public const int ITEMS_PER_PAGE = 4;
// GET: Admin/Post
public async Task<IActionResult> Index ([Bind(Prefix="page")]int pageNumber) {

    if (pageNumber == 0)
        pageNumber = 1;


    var listPosts = _context.Posts
        .Include (p => p.Author)
        .Include (p => p.PostCategories)
        .ThenInclude (c => c.Category)
        .OrderByDescending(p => p.DateCreated);

    _logger.LogInformation(pageNumber.ToString());

    // Lấy tổng số dòng dữ liệu
    var totalItems = listPosts.Count ();
    // Tính số trang hiện thị (mỗi trang hiện thị ITEMS_PER_PAGE mục)
    int totalPages = (int) Math.Ceiling ((double) totalItems / ITEMS_PER_PAGE);

    if (pageNumber > totalPages)
        return RedirectToAction(nameof(PostController.Index), new {page = totalPages});


    var posts = await listPosts
                    .Skip (ITEMS_PER_PAGE * (pageNumber - 1))
                    .Take (ITEMS_PER_PAGE)
                    .ToListAsync();

    // return View (await listPosts.ToListAsync());
    ViewData["pageNumber"] = pageNumber;
    ViewData["totalPages"] = totalPages;

    return View (posts.AsEnumerable());
}

View Index.cshtml có nội dung như sau:

@model IEnumerable<mvcblog.Models.Post>

@{
    ViewData["Title"] = "Index";
    Layout = "_Layout";
}

<h1>Danh mục</h1>

<p>
    <a asp-action="Create">Tạo bài viết mới</a>
</p>
<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.Title)
            </th>

            <th>
                @Html.DisplayNameFor(model => model.AuthorId)
            </th>
            <th>
                Ngày tạo <br/>
                Cập nhật
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Published)
            </th>
            <th>Chuyên mục</th>
            <th></th>
        </tr>
    </thead>
    <tbody>
@foreach (var item in Model) {
        <tr>
            <td>
                
                <a title="xem chi tiết" asp-action="Details" asp-route-id="@item.PostId">
                    <strong>@Html.DisplayFor(modelItem => item.Title)</strong>
                </a>
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Author.UserName)
            </td>
            <td>
                @item.DateCreated.ToShortDateString()
                <br>
                @item.DateUpdated.ToShortDateString()
            </td>

            <td>
                @Html.DisplayFor(modelItem => item.Published)
            </td>
            <td>
                @Html.Raw(string.Join("<br>",
                    item.PostCategories
                    .Select(p => p.Category)
                    .ToList()
                    .Select(c => $"<i>{c.Title}</i>")))
            </td>

            <td>
                <a asp-action="Edit" asp-route-id="@item.PostId">Sửa</a> |
                <a asp-action="Delete" asp-route-id="@item.PostId">Xóa</a>
            </td>
        </tr>
}
    </tbody>
</table>


@{

    Func<int?,string> generateUrl = (int? _pagenumber)  => {
        return Url.ActionLink("Index", null, new {page = _pagenumber});
    };

    var datapaging = new {
        currentPage = ViewBag.pageNumber,
        countPages  = ViewBag.totalPages,
        generateUrl =  generateUrl
    };

}
<partial name="_Paging" model="@datapaging" />

Một số điểm chú ý của code trên

Có tích hợp Paging (xem Partial phân trang)

Khi truy vấn lấy danh sách các Post, mới mỗi Model Post cũng tải luôn Author và danh sách các Categories của Post, nên thực hiện LINQ đó như sau:

var listPosts = _context.Posts
    .Include (p => p.Author)                // Tải Author
    .Include (p => p.PostCategories)        // Tải các PostCategory
    .ThenInclude (c => c.Category)          // Mỗi PostCateogry tải luôn Categtory
    .OrderByDescending(p => p.DateCreated);

Danh sách các Post chuyển đến View để hiện thị chỉ lấy từng trang một, do đó thực hiện tiếp LINQ

var posts = await listPosts
                .Skip (ITEMS_PER_PAGE * (pageNumber - 1))       // Bỏ qua các trang trước
                .Take (ITEMS_PER_PAGE)                          // Lấy số phần tử của trang hiện tại
                .ToListAsync();

posts này sẽ chuyển đến View để hiện thị

Trong Index.cshtml, phần tích hợp Paging thực hiện tương tự ở các hướng dẫn khác. Lưu ý ở đây có cột hiện thị tên các danh mục của bài Post, các tên này được lấy ra bằng LINQ như sau:

@Html.Raw(string.Join("<br>",
    item.PostCategories
    .Select(p => p.Category)
    .ToList()
    .Select(c => $"<i>{c.Title}</i>")))

Trang cập nhật bài viết

Action Edit, tương ứng với View Edit.cshtml có chức năng cập nhật nội dung bài viết có sẵn, xây dựng chức năng này với code như sau:

// GET: Admin/Post/Edit/5
public async Task<IActionResult> Edit (int? id) {
    if (id == null) {
        return NotFound ();
    }

    // var post = await _context.Posts.FindAsync (id);
    var post = await _context.Posts.Where (p => p.PostId == id)
        .Include (p => p.Author)
        .Include (p => p.PostCategories)
        .ThenInclude (c => c.Category).FirstOrDefaultAsync ();
    if (post == null) {
        return NotFound ();
    }

    ViewData["userpost"] = $"{post.Author.UserName} {post.Author.FullName}";
    ViewData["datecreate"] = post.DateCreated.ToShortDateString ();

    // Danh mục chọn
    var selectedCates = post.PostCategories.Select (c => c.CategoryID).ToArray ();
    var categories = await _context.Categories.ToListAsync ();
    ViewData["categories"] = new MultiSelectList (categories, "Id", "Title", selectedCates);

    return View (post);
}

// POST: Admin/Post/Edit/5
// To protect from overposting attacks, enable the specific properties you want to bind to, for
// more details, see http://go.microsoft.com/fwlink/?LinkId=317598.
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit (int id, [Bind ("PostId,Title,Description,Slug,Content")] PostBase post) {

    if (id != post.PostId) {
        return NotFound ();
    }



    // Phát sinh Slug theo Title
    if (ModelState["Slug"].ValidationState == ModelValidationState.Invalid) {
        post.Slug = Utils.GenerateSlug (post.Title);
        ModelState.SetModelValue ("Slug", new ValueProviderResult (post.Slug));
        // Thiết lập và kiểm tra lại Model
        ModelState.Clear ();
        TryValidateModel (post);
    }

    if (selectedCategories.Length == 0) {
        ModelState.AddModelError (String.Empty, "Phải ít nhất một chuyên mục");
    }

    bool SlugExisted = await _context.Posts.Where (p => p.Slug == post.Slug && p.PostId != post.PostId).AnyAsync();
    if (SlugExisted) {
        ModelState.AddModelError (nameof (post.Slug), "Slug đã có trong Database");
    }

    if (ModelState.IsValid) {

        // Lấy nội dung từ DB
        var postUpdate = await _context.Posts.Where (p => p.PostId == id)
            .Include (p => p.PostCategories)
            .ThenInclude (c => c.Category).FirstOrDefaultAsync ();
        if (postUpdate == null) {
            return NotFound ();
        }

        // Cập nhật nội dung mới
        postUpdate.Title = post.Title;
        postUpdate.Description = post.Description;
        postUpdate.Content = post.Content;
        postUpdate.Slug = post.Slug;
        postUpdate.DateUpdated = DateTime.Now;

        // Các danh mục không có trong selectedCategories
        var listcateremove = postUpdate.PostCategories
                                       .Where(p => !selectedCategories.Contains(p.CategoryID))
                                       .ToList();
        listcateremove.ForEach(c => postUpdate.PostCategories.Remove(c));

        // Các ID category chưa có trong postUpdate.PostCategories
        var listCateAdd = selectedCategories
                            .Where(
                                id => !postUpdate.PostCategories.Where(c => c.CategoryID == id).Any()
                            ).ToList();

        listCateAdd.ForEach(id => {
            postUpdate.PostCategories.Add(new PostCategory() {
                PostID = postUpdate.PostId,
                CategoryID = id
            });
        });

        try {

            _context.Update (postUpdate);

            await _context.SaveChangesAsync();
        } catch (DbUpdateConcurrencyException) {
            if (!PostExists (post.PostId)) {
                return NotFound ();
            } else {
                throw;
            }
        }
        return RedirectToAction (nameof (Index));
    }

    var categories = await _context.Categories.ToListAsync ();
    ViewData["categories"] = new MultiSelectList (categories, "Id", "Title", selectedCategories);
    return View (post);
}

Edit.cshtml

@model mvcblog.Models.PostBase

@{
    ViewData["Title"] = "Edit";
    Layout = "_Layout";
}

<h1>Edit</h1>

<h4>Cập nhật bài viết</h4>
<hr />
<div class="row">
    <div class="col-md-8">
        <form asp-action="Edit">
            <div asp-validation-summary="All" class="text-danger"></div>

            <input type="hidden" asp-for="PostId" />
            <div class="form-group">
                <label class="control-label">Chọn danh mục</label>
                @Html.ListBox("selectedCategories", ViewBag.categories, 
                    new {@class="w-100", id = "selectedCategories"})
            </div>
            <div class="form-group">
                <label asp-for="Title" class="control-label"></label>
                <input asp-for="Title" class="form-control" />
                <span asp-validation-for="Title" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Description" class="control-label"></label>
                <textarea asp-for="Description" class="form-control"></textarea>
                <span asp-validation-for="Description" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Slug" class="control-label"></label>
                <input asp-for="Slug" class="form-control" />
                <span asp-validation-for="Slug" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Content" class="control-label"></label>
                <textarea asp-for="Content" class="form-control"></textarea>
                <span asp-validation-for="Content" class="text-danger"></span>
            </div>
            <div class="form-group form-check">
                <label class="form-check-label">
                    <input class="form-check-input" asp-for="Published" /> @Html.DisplayNameFor(model => model.Published)
                </label>
            </div>
            <div class="form-group">Tác giả: <strong>@ViewBag.userpost</strong></div>
            <div class="form-group">Ngày tạo: <strong>@ViewBag.datecreate</strong></div>
            
            <div class="form-group">
                <input type="submit" value="Cập nhật" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

<div>
    <a asp-action="Index">Quay lại danh sách</a>
</div>

@section Scripts
{
    @await Html.PartialAsync("_Summernote", new {height = 200, selector = "#Content"})
    
    <script src="~/lib/multiple-select/multiple-select.min.js"></script>
    <link rel="stylesheet" href="~/lib/multiple-select/multiple-select.min.css" />
    <script>
          $('#selectedCategories').multipleSelect({
                selectAll: false,
                keepOpen: false,
                isOpen: false
            });
    </script>

}

Như vậy đến đây có đủ chức năng về quản lý các bài viết Post

Mã nguồn tham khảo ASP_NET_CORE/mvcblog, hoặc tải về bản bài này ex068-post


Đăng ký nhận bài viết mới