2
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ASP.NET Coreを使ったToDoアプリの作成(後半)

Posted at

はじめに

本記事では実際に手を動かしながらアプリを作成する過程を書いていきたいと思います。
助言、アドバイス、間違い等ございましたら、ご指摘いただけると幸いです。

後半概要

  • 前半で調べた知識をもとにToDoアプリを作成していく

環境

  • OS : Windows10
  • DB : Postgres14.1
  • IDE : Visual Studio 2019
  • 使用言語 : C#, Javascript
  • フレームワーク : ASP.net Core 5.1, .Net 5.0
  • ライブラリ : jquery, bootstrap5, lodash
  • その他 : pgAdmin 4

設計

画面設計

画像5.png
まずは深く考えずに最低限の機能だけ持たせます。
ヘッダーフッターは正直いらないですが、せっかく用意してくれたので一応残しておきます。
ユーザーが追加ボタンを押下すると、アコーディオンが開き項目を入力します。

登録ボタンが押下されると、入力した項目をToDoリストに表示します。
検索フォームではToDoのタイトルを検索し、シームレスに結果をToDoリストへ表示します。

フローチャート

モデル

ToDoTaskクラスを作成します。
プロパティとして必要なのは

  • タスクのタイトル
  • タスクの説明
  • タスクの完了フラグ

メソッドとしては

  • タイトル、説明を更新する処理
  • 完了状態にする処理
    かなと思っています。

準備

下準備としてDBを作成し、アプリと連携させます。

  • NugetパッケージからEntityFrameworkとNpgSqlのインストール
  • PgAdminからデータベースを新規作成

EntityFrameworkは5.0.17、
NpgSqlは5.0.10をインストールしました。

pgAdminでDBを作成します。
画像7.png

アプリ設定ファイルに接続文字列を追加

appsetting.json
+ "ConnectionStrings": {
+   "DefaultConnection": "Host=localhost;Database=ToDoManage;Username=username;Password=password"
+ }

EntityFrameworkでDBを管理するためにモデルを作成します。

設計を元にToDoTaskクラスを作成します。

Task.cs
public class ToDoTask
{
    public ToDoTask(string title, string description)
    {
        this.title = title;
        this.description = description;
        this.isDone = false;
    }

    [Key]
    public int taskId { get; set; }
    public string title { get; set; }
    public string description { get; set; }
    public bool isDone { get; set; }

    public void Update(string title, string description)
    {
        this.title = title;
        this.description = description;
    }

    public void Done()
    {
        this.isDone = !this.isDone;
    }
}

DBへのCRUDをEntityFramework(以下EF)を通して行うためにDataContextクラスを作成。

DataContext.cs
public class DataContext : DbContext, IDataContext
{
    public DataContext(DbContextOptions<DataContext> options) : base(options)
    {

    }

    public DbSet<ToDoTask> ToDoTask { get; set; }
    public async Task<int> SaveChanges()
    {
        return await base.SaveChangesAsync();
    }
}

その後、作成したDataContextクラスをサービスへ登録します。

Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllersWithViews();

+     services.AddDbContext<DataContext>(options =>
+     {
+         options.UseNpgsql(Configuration.GetConnectionString("DefaultConnection"));
+     });
+     services.AddScoped<IDataContext>(provider => provider.GetService<DataContext>());

}

マイグレーション実行

これでDBが完成しました。

参考サイト

コーディング

とりあえず画面モックを作成しました。
画像8.png

index.cshtml
@{
    ViewData["Title"] = "Home Page";
}

<div class="container">
    <div class="row my-1">
        <div class="col-2 text-center">
            <button id="openAddTaskForm" type="button" class="btn btn-outline-primary" data-bs-toggle="collapse" data-bs-target="#addTaskAccordion" aria-expanded="false" aria-controls="addTaskAccordion">タスク追加</button>
        </div>
        <div class="col-10">
            <form>
                <input type="text" class="form-control" placeholder="タスクを検索する" aria-label="searchTask">
            </form>
        </div>
    </div>
    <div class="row collapse mt-3" id="addTaskAccordion">
        <div class="card card-body">
            <form id="addTaskForm">
                <div class="mb-3">
                    <label for="newTaskTitle" class="form-label">タスク名</label>
                    <input type="text" class="form-control" name="title" id="newTaskTitle">
                </div>
                <div class="mb-3">
                    <label for="newTaskDescription" class="form-label">タスクの詳細</label>
                    <textarea class="form-control" name="description" id="newTaskDescription" rows="3"></textarea>
                </div>
                <div class="mb-3">
                    <button id="addTask" type="button" class="btn btn-primary">追加</button>
                </div>
            </form>
        </div>
    </div>
</div>
<div class="mt-5">
    <div class="accordion accordion--custom" id="accordionCheckBox">
        <div class="accordion-item">
            <h2 class="accordion-header" id="accordionHeader_1">
                <input type="checkbox" class="form-check-input">
                <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#accordionCollapse_1" aria-expanded="true" aria-controls="accordionCollapse_1">
                    HomeWork
                </button>
            </h2>
            <div id="accordionCollapse_1" class="accordion-collapse collapse" aria-labelledby="accordionHeader_1" data-bs-parent="#accordionCheckBox">
                <div class="accordion-body">
                    TodoHomeWork
                </div>
            </div>
        </div>
        <div class="accordion-item">
            <h2 class="accordion-header" id="accordionHeader_2">
                <input type="checkbox" class="form-check-input">
                <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#accordionCollapse_2" aria-expanded="false" aria-controls="accordionCollapse_2">
                    Study
                </button>
            </h2>
            <div id="accordionCollapse_2" class="accordion-collapse collapse" aria-labelledby="accordionHeader_2" data-bs-parent="#accordionCheckBox">
                <div class="accordion-body">
                    Todomathstudy
                </div>
            </div>
        </div>
        <div class="accordion-item">
            <h2 class="accordion-header" id="accordionHeader_3">
                <input type="checkbox" class="form-check-input">
                <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#accordionCollapse_3" aria-expanded="false" aria-controls="accordionCollapse_3">
                    MakeDinner
                </button>
            </h2>
            <div id="accordionCollapse_3" class="accordion-collapse collapse" aria-labelledby="accordionHeader_3" data-bs-parent="#accordionCheckBox">
                <div class="accordion-body">
                    Makedinner
                </div>
            </div>
        </div>
    </div>
</div>
<div class="mt-5">
    <nav aria-label="Search result pages">
        <ul class="pagination justify-content-center">
            <li class="page-item disabled">
                <a class="page-link" href="#" tabindex="-1" aria-disabled="true">Previous</a>
            </li>
            <li class="page-item"><a class="page-link" href="#">1</a></li>
            <li class="page-item"><a class="page-link" href="#">2</a></li>
            <li class="page-item"><a class="page-link" href="#">3</a></li>
            <li class="page-item">
                <a class="page-link" href="#">Next</a>
            </li>
        </ul>
    </nav>
</div>

画面内で使っているbootstrapのアコーディオンとチェックボックスを合体させたものは以下記事に書いてあります。

TagHelperの作成

アコーディオンチェックボックスを後の実装の為にTagHelper化する。

AccrodionCheckboxTagHelper.cs
public class AccordionCheckboxTagHelper : TagHelper
{
    [HtmlAttributeName("model")]
    public ToDoTask task { get; set; }
    [HtmlAttributeName("number")]
    public int number { get; set; }


    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        output.TagName = "div";

        output.AddClass("accordion-item", HtmlEncoder.Default);
        output.Attributes.Add("data-task-id", task.taskId);

        var content = output.Content;

        var header = new TagBuilder("h2");
        header.AddCssClass("accordion-header");
        header.Attributes.Add("id", $"accordionHeader_{number}");

        var checkbox = new TagBuilder("input");
        checkbox.AddCssClass("form-check-input");
        checkbox.Attributes.Add("type", "checkbox");
        if (task.isDone)
        {
            checkbox.Attributes.Add("checked", "");
        }
        header.InnerHtml.AppendHtml(checkbox);

        var accordionButton = new TagBuilder("button");
        accordionButton.AddCssClass("accordion-button");
        accordionButton.Attributes.Add("type", "button");
        accordionButton.Attributes.Add("data-bs-toggle", "collapse");
        accordionButton.Attributes.Add("data-bs-target", $"#accordionCollapse_{number}");
        accordionButton.Attributes.Add("area-expanded", "true");
        accordionButton.Attributes.Add("area-controls", $"accordionCollapse_{number}");
        accordionButton.InnerHtml.Append($"{task.title}");
        header.InnerHtml.AppendHtml(accordionButton);

        content.SetHtmlContent(header);

        var collapseDiv = new TagBuilder("div");
        collapseDiv.AddCssClass("accordion-collapse collapse");
        collapseDiv.Attributes.Add("id", $"accordionCollapse_{number}");
        collapseDiv.Attributes.Add("aria-labelledby", $"accordionHeader_{number}");
        collapseDiv.Attributes.Add("data-bs-parent", $"#accordionCheckBox");
        
        var bodyDiv = new TagBuilder("div");
        bodyDiv.AddCssClass("accordion-body");
        bodyDiv.InnerHtml.Append($"{task.description}");
        collapseDiv.InnerHtml.AppendHtml(bodyDiv);

        content.AppendHtml(collapseDiv);

    }
}

上で作ったパーツをHtmlに組み込みます

index.cshtml
<div class="mt-5" id="partialListView">
    <div class="accordion accordion--custom" id="accordionCheckBox">
+        <accordion-checkbox model=task number=i></accordion-checkbox>
-        <div class="accordion-item">
-            <h2 class="accordion-header" id="accordionHeader_1">
-                <input type="checkbox" class="form-check-input">
-                <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#accordionCollapse_1" aria-expanded="true" aria-controls="accordionCollapse_1">
-                    HomeWork
-                </button>
-            </h2>
-            <div id="accordionCollapse_1" class="accordion-collapse collapse" aria-labelledby="accordionHeader_1" data-bs-parent="#accordionCheckBox">
-                <div class="accordion-body">
-                    TodoHomeWork
-                </div>
-            </div>
-        </div>
    </div>
</div>

タスク追加ボタン処理

作業に入る前に、先ほどTagHelper化した部分をさらにPartialView化します。
理由としては、タスクを追加した際に画面を遷移させずに即時変更を反映したいからです。

_TaskView.partial.cshtml
@model List<ToDoTask>

<div class="accordion accordion--custom" id="accordionCheckBox">
    @{ int index = 0;
        foreach (ToDoTask task in Model)
        {
            index += 1;
            <accordion-checkbox model=task number=index></accordion-checkbox>
        }
    }
</div>
index.cshtml
<div class="mt-5" id="partialListView">
+    <partial name="~/Views/partial/_TaskView.partial.cshtml" model="Model" />
-    <div class="accordion accordion--custom" id="accordionCheckBox">
-        <accordion-checkbox model=task number=i></accordion-checkbox>
-    </div>
</div>

タスク追加ボタンを押したときの処理を追加します。

index.cshtml
+ <script>
+ $(function () {
+    $('#addTaskBtn').on('click', function () {
+         const formData = $('#addTaskForm').serialize();
+
+         $.ajax({
+             url: "/AddTask",
+             data: formData,
+             dataType: "html",
+             type: "POST",
+         }).done(function (res) {
+             $('#partialListView').html(res);
+         })
+     })
+ })
+ </script>

次にViewからのAjax通信を受け取るコントローラーのメソッドを作成します。

HomeController.cs
public class HomeController : Controller
{
    private readonly IDataContext _context;

    public HomeController(IDataContext context)
    {
        _context = context;
    }

    public IActionResult Index()
    {
        return View();
    }

    public IActionResult Privacy()
    {
        return View();
    }

    [Route("/AddTask")]
    [HttpPost]
    public async Task<IActionResult> AddTask(string title, string description)
    {
        var model = new ToDoTask(title, description);
        _context.ToDoTask.Add(model);
        await _context.SaveChanges();

        return PartialView("~/Views/partial/_TaskView.partial.cshtml", await GetTasks());
        
    }

    public async Task<List<ToDoTask>> GetTasks()
    {
        List<ToDoTask> list = await _context.ToDoTask.OrderBy(x => x.taskId).ToListAsync();
        if (list == null)
        {
            list = new List<ToDoTask>();
        }
        return list;
    }
}

上のAddTaskメソッドを作成し、返り値として上で作成した、PartialViewと追加した後のmodelのリストを返却しています。
画像9.gif

いい感じになりました。

paginationの作成

タスクを追加した時や、ページを移動した時等にpartialだけ更新させたいのでpaginationもpartialに含めます。

PaginatedListは公式チュートリアルを少し改変しただけです。
(ライブラリを使うべきだと思いますが、練習なのであまり外部に頼らずに作成しています。)

PaginatedList.cs
public class PaginatedList<T> : List<T>
{
    public int PageIndex { get; private set; }
    public int TotalPages { get; private set; }

    public PaginatedList(List<T> items, int count, int pageIndex, int pageSize)
    {
        PageIndex = pageIndex;
        TotalPages = (int)Math.Ceiling(count / (double)pageSize);

        this.AddRange(items);
    }

    public bool HasPreviousPage => PageIndex > 1;

    public bool HasNextPage => PageIndex < TotalPages;

    public static PaginatedList<T> Create(List<T> source, int pageIndex, int pageSize = 10)
    {
        var count = source.Count();
        var items = source.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList();
        return new PaginatedList<T>(items, count, pageIndex, pageSize);
    }
}
_TaskView.partial.cshtml
@model PaginatedList<ToDoTask>

<div class="mt-5">
    <div class="accordion accordion--custom" id="accordionCheckBox">
        @{ int index = 0;
            foreach (ToDoTask task in Model)
            {
                index += 1;
                <accordion-checkbox model=task number=index></accordion-checkbox>
            }
        }
    </div>
</div>
+ <script>
+     $(function () {
+         $('.pagination-link').on('click', function () {
+             $.ajax({
+                 url: "/PaginateChange",
+                 data: { pageIndex: $(this).data("pagenumber") },
+                 dataType: "html",
+                 type: "POST",
+             }).done(function (res) {
+                 $('#partialListView').html(res);
+             })
+         })
+     })
+ </script>
+ @{
+     var prevDisabled = !Model.HasPreviousPage ? "disabled" : "";
+     var nextDisabled = !Model.HasNextPage ? "disabled" : "";
+     var pageRange = 1;
+     var startPage = Math.Max(Model.PageIndex - pageRange, 1);
+     var endPage = Math.Min(Model.PageIndex + pageRange, Model.TotalPages);
+     var active = "";
+ }
+ <div class="mt-5">
+     <nav aria-label="Search result pages">
+         <ul class="pagination justify-content-center">
+             <li class="page-item @prevDisabled">
+                 <a class="page-link pagination-link" data-pageNumber="@(Model.PageIndex - 1)">Prev</a>
+             </li>
+             @if (Model.PageIndex > 2)
+             {
+                 <li class="page-item">
+                     <a class="page-link pagination-link" href="javascript:void(0);" data-pageNumber="1">1</a>
+                 </li>
+             }
+             @if (startPage > pageRange)
+             {
+                 <li class="page-item disabled">
+                     <a class="page-link">...</a>
+                 </li>
+             }
+             @for (int i = startPage; i <= endPage; i++)
+             {
+                 active = Model.PageIndex == i ? "active" : "";
+                 <li class="page-item @active">
+                     <a data-pageNumber="@i" href="javascript:void(0);" class="page-link pagination-link">@i</a>
+                 </li>
+             }
+             @if ((Model.TotalPages - 1) > endPage)
+             {
+                 <li class="page-item disabled">
+                     <a class="page-link">...</a>
+                 </li>
+             }
+             @if (Model.TotalPages > (Model.PageIndex + 1))
+             {
+                 <li class="page-item">
+                     <a class="page-link pagination-link" href="javascript:void(0);" data-pageNumber="@Model.TotalPages">@Model.TotalPages</a>
+                 </li>
+             }
+             <li class="page-item @nextDisabled">
+                 <a class="page-link pagination-link" href="javascript:void(0);" data-pageNumber="@(Model.PageIndex + 1)">Next</a>
+             </li>
+         </ul>
+     </nav>
+ </div>
HomeController.cs
+ [Route("/PaginateChange")]
+ [HttpPost]
+ public async Task<IActionResult> PaginateChange(int pageIndex)
+ {
+     return PartialView("~/Views/partial/_TaskView.partial.cshtml", PaginatedList<ToDoTask>.Create(await GetTasks(), pageIndex));
+ }

これでページを切り替え時とタスク追加時に画面全体に更新がかからずにパーツだけを更新することができました。

検索処理の実装

最後に検索処理の実装をしていきます。
検索の手法はインクリメンタルサーチを実装していきたいと思います。

インクリメンタルサーチとは、キーワード検索を行う際に、利用者が文字を入力するたびに検索を実行する方式。
検索語全体を入力する前に検索を開始し、一文字進むごとに検索結果が更新されていく。

上記を実装するにあたって、普通にinputイベントとして検索処理を実装していくと、ユーザーから入力があるたびにイベントが発火してしまうので、
lodash.jsのdebounce関数を利用して負荷対策を行います。

index.cshtml
$('#addTaskBtn').on('click', function () {
    var pageNumber = $('.page-item.active').find('.page-link').data('pagenumber');
    const formData = $('#addTaskForm').serialize() + `&pageIndex=${pageNumber}`;

    $.ajax({
        url: "/AddTask",
        data: formData,
        dataType: "html",
        type: "POST",
    }).done(function (res) {
        $('#partialListView').html(res);
    })
})

+ $('#searchTask').on('input', _.debounce(function () {
+     var searchText = $(this).val();
+ 
+     $.ajax({
+         url: "/SearchTask",
+         data: { searchText: searchText },
+         dataType: "html",
+         type: "POST",
+     }).done(function (res) {
+         $('#partialListView').html(res);
+     })
+ }, 500))
HomeContrller.cs
[Route("/SearchTask")]
[HttpPost]
public async Task<IActionResult> SearchTask(string searchText)
{
    return PartialView("~/Views/partial/_TaskView.partial.cshtml", PaginatedList<ToDoTask>.Create(await GetTasks(searchText), 1));
}

- public async Task<List<ToDoTask>> GetTasks()
+ public async Task<List<ToDoTask>> GetTasks(string searchText = "")
{
-     List<ToDoTask> list = await _context.ToDoTask.OrderBy(x => x.taskId).ToListAsync();
+     List<ToDoTask> list = null;
+     if (string.IsNullOrEmpty(searchText))
+     {
+         list = await _context.ToDoTask.AsNoTracking().OrderBy(x => x.taskId).ToListAsync();
+     }
+     else
+     {
+         list = await _context.ToDoTask.AsNoTracking().Where(x => x.title.Contains(searchText)).OrderBy(x => x.taskId).ToListAsync();
+     }
    if (list == null)
    {
        list = new List<ToDoTask>();
    }
    return list;
}

画像10.gif

これでインクリメンタルサーチの完成です。

終わりに

今回は、調査と実践の前後半で記事を作成していました。
あくまで自分の理解を深める、アウトプットしたいという点に重きを置いているので、
完全な自己満足の記事になってしまいましたが、少しでも誰かの役に立てばうれしいかなと思います。
コードだらけで読みづらいので、ここら辺は次の課題としておいておきます。
使ったテクニックの部分だけ取り出して別記事としてまとめたほうが今後の為にも良いかもしれません。

ご指摘やアドバイスなどあればぜひお願いします。
GitHub

補足

記事には記述していませんが、タスクを追加した際のアラート機能も追加してあります。

参考サイト

2
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?