.NET WebAPI With Vue.js and Docker - Todolist

2023-09-11

實作使用 .NET WebAPI 作為後端,Vue.js 作為前端,並且使用 Docker 進行容器化的部署方式,設計一個簡單的 Todolist 應用程式。

logo

說明

GitHub

建立 ASP.NET Core WebAPI 專案

項目 名稱
解決方案 Todolist
WebAPI 專案 TodolistAPI
Vue 專案 TodolistVue

先進行 git 初始化:

git init

WebAPI

Models

使用 cli 安裝 Entity Framework Core:

dotnet add package Microsoft.EntityFrameworkCore.SqlServer   
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design

接著加入 TodoItem Model:

TodoItem.cs

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

public class TodoItem
{
    [Key]
    public int TodoItemID { get; set; }

    [Required]
    [MaxLength(255)]
    public string? Title { get; set; }

    [MaxLength]
    public string? Description { get; set; }

    public DateTime? DueDate { get; set; }

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

    public Category? Category { get; set; }
}

Category.cs

using System.ComponentModel.DataAnnotations;

public class Category
{
    [Key]
    public int CategoryID { get; set; }

    [Required]
    [MaxLength(255)]
    public string? CategoryName { get; set; }
}

最後加上 DBContext,來連結 Models 與 Database。

TodoContext.cs

using Microsoft.EntityFrameworkCore;

namespace TodolistService.Models;

public class TodoContext : DbContext
{
    public TodoContext(DbContextOptions<TodoContext> options) : base(options) { }

    public DbSet<Category> Categories { get; set; }
    public DbSet<TodoItem> TodoItems { get; set; }
}

連線字串設定在 appsettings.json,簡化使用 LocalDB 以及 Integrated Security 的連線方式來示範,實際專案可以用明確的 User ID 與 Password 以及搭配正式的 SQL Server 來使用。

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "ConnectionStrings": {
    "DefaultConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=TodoListDB;Integrated Security=true;TrustServerCertificate=true"
  },
  "AllowedHosts": "*"
}

接著進行 Migration (如果尚未安裝 dotnet-ef 必須先進行安裝):

dotnet tool install --global dotnet-ef
dotnet ef migrations add InitialCreate
dotnet ef database update

完成 Datbase Update 後,會發現 Entity Framework Core 已經自動在 LocalDB 中建立了 TodoListDB Database,並且建立了 Categories 與 TodoItems Table。

Controllers

接著建立 TodoItemsController 來處理 TodoItem 的 CRUD。

透過 Scaffold-DbContext 來建立 Controller,只進行以下的修改,加入 Categories 以及回應 TodiItems 使用 Eager Loading:

// GET: api/Categories
[HttpGet("/api/Categories")]
public async Task<ActionResult<IEnumerable<Category>>> GetCategories()
{
    return await _context.Categories.ToListAsync();
}

// GET: api/TodoItems
[HttpGet]
public async Task<ActionResult<IEnumerable<TodoItem>>> GetTodoItems()
{
  if (_context.TodoItems == null)
  {
      return NotFound();
  }
    return await _context.TodoItems.Include(i => i.Category).ToListAsync();
}

完成後啟用網站,有看到 Swagger 表示已經可以進行 CRUD 了,

使用 POST 輸入一筆資料,同時建立 TodoItem 以及 Category (能夠這麼方便全是因為 Model Binding 的幫忙 😎):

{
  "title": "Buy Milk",
  "description": "🥛",
  "dueDate": "2023-09-11T12:00:00.939Z",
  "category": {
    "categoryName": "Life"
  }
}

啟用 CORS

在 Startup.cs 中加入 CORS Policy:

var MyAllowSpecificOrigins = "_myAllowSpecificOrigins";

builder.Services.AddCors(options =>
{
    options.AddPolicy(name: MyAllowSpecificOrigins,
                      policy =>
                      {
                          policy.AllowAnyHeader()
                                .AllowAnyMethod()
                                .AllowAnyOrigin();
                      });
});

app.UseCors(MyAllowSpecificOrigins);

Vue

首先加入專案,類型選擇「獨立 JavaScript Vue 專案」。

首先調整 index.html 加入對 Bootstrap 及 Google Icon 的引用:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <link rel="icon" href="/favicon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vite App</title>
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">

    <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
          rel="stylesheet">
</head>
<body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
</body>
</html>

src/App.vue

Template 的部分:

<template>
    <div class="container">
        <div class="row text-center my-4">
            <img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" />
            <h1>Vue.js Todolist</h1>
        </div>
        <div class="row">
            <div class="col-md-4 p-3">
                <Form :postDataObj="postDataObj"
                      :editMode="editMode"
                      :categories="categories"
                      @init-post="initPostData"
                      @load-todo="loadTodos" />
            </div>
            <div class="col-md-8">
                <Table :todos="todos" @edit-todo="editTodoItem" @add-todo="addTodoItem" @delete-todo="deleteTodoItem" />
            </div>
        </div>
    </div>
</template>

Script 與 Style 的部分:

<script>
    import Form from './components/Form.vue';
    import Table from './components/Table.vue';

    export default {
        components: {
            Form,
            Table,
        },
        data() {
            return {
                postDataObj: {
                    title: '',
                    description: '',
                    dueDate: this.getDatetimeNow(),
                },
                editMode: false,
                todos: [],
                categories: [],
            };
        },
        mounted() {
            this.loadTodos();
            this.loadCategories();
            this.initPostData();
        },
        methods: {
            getDatetimeNow() {
                return new Date().toISOString().slice(0, 16);
            },
            initPostData() {
                this.postDataObj = {
                    title: '',
                    description: '',
                    dueDate: this.getDatetimeNow(),
                };
                document.getElementById('title').focus();
            },
            loadTodos() {
                fetch(`${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/`, { method: 'GET' })
                    .then(response => response.json())
                    .then(data => {
                        this.todos = data;
                    })
                    .catch(error => {
                        console.error('Error fetching data:', error);
                    });
            },
            loadCategories() {
                fetch(`${import.meta.env.VITE_API_BASE_URL}/api/Categories/`)
                    .then(response => response.json())
                    .then(data => {
                        this.categories = data.map(item => item);
                    })
                    .catch(error => {
                        console.error('Error fetching data:', error);
                    });
            },
            editTodoItem(todo) {
                this.postDataObj.title = todo.title;
                this.postDataObj.description = todo.description;
                this.postDataObj.dueDate = todo.dueDate;
                this.postDataObj.categoryId = todo.category ? todo.category.categoryID : null;
                this.postDataObj.TodoItemID = todo.todoItemID;

                // Change the button label and action
                this.editMode = true;
            },
            addTodoItem() {
                this.editMode = false;
                this.initPostData();
            },
            deleteTodoItem(todo) {
                const confirmDelete = confirm("Are you sure you want to delete this item?");
                if (confirmDelete) {
                    fetch(`${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/${todo.todoItemID}`, {
                        method: 'DELETE'
                    })
                        .then(response => {
                            if (response.ok) {
                                this.loadTodos();
                                this.initPostData();
                                console.log('Successfully deleted todo item.');
                            } else {
                                console.error('Failed to delete todo item.');
                            }
                        })
                        .catch(error => {
                            console.error('Error deleting todo item:', error);
                        });
                }
            },
            postData() {
                const requestOptions = {
                    method: this.editMode ? 'PUT' : 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify(this.postDataObj)
                };

                const url = this.editMode
                    ? `${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/${this.postDataObj.TodoItemID}`
                    : `${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/`;

                if (requestOptions.method == 'PUT') {
                    fetch(url, requestOptions)
                        .then(response => {
                            this.loadTodos();
                            console.log('Success fetching data:', response);
                        })
                        .catch(error => {
                            console.error('Error fetching data:', error);
                        });
                }
                else {
                    fetch(url, requestOptions)
                        .then(response => response.json())
                        .then(data => {
                            this.initPostData();
                            this.loadTodos();
                            console.log('Success fetching data:', data);
                        })
                        .catch(error => {
                            console.error('Error fetching data:', error);
                        });
                }
            }
        }
    };
</script>

<style>
    @import url('https://fonts.googleapis.com/css2?family=Gochi+Hand&display=swap');
    body {
        font-family: 'Gochi Hand', cursive;
        font-size: 1.2em;
    }
</style>

src/components/form.vue

Template 的部分:

<template>
    <form @submit.prevent="postData">
        <div class="mb-3 row">
            <label for="title" class="col-md-4 col-form-label">Title</label>
            <div class="col-md-8">
                <input type="text" id="title" class="form-control" v-model="postDataObj.title" required>
            </div>
        </div>
        <div class="mb-3 row">
            <label for="description" class="col-md-4 col-form-label">Description</label>
            <div class="col-md-8">
                <input type="text" id="description" class="form-control" v-model="postDataObj.description" required>
            </div>
        </div>
        <div class="mb-3 row">
            <label for="dueDate" class="col-md-4 col-form-label">Due Date</label>
            <div class="col-md-8">
                <input type="datetime-local" id="dueDate" class="form-control" v-model="postDataObj.dueDate" required>
            </div>
        </div>
        <div class="mb-3 row">
            <label for="category" class="col-md-4 col-form-label">Category</label>
            <div class="col-md-8">
                <select id="category" class="form-select" v-model="postDataObj.categoryId" required>
                    <option v-for="category in categories" :key="category.categoryID" :value="category.categoryID">{{ category.categoryName }}</option>
                </select>
            </div>
        </div>
        <div class="mb-3">
            <div v-if="editMode == true">
                <button type="submit" class="btn btn-success">Edit</button>
            </div>
            <div v-else>
                <button type="submit" class="btn btn-primary">Submit</button>
            </div>
        </div>
    </form>
</template>

Script 的部分:

<script>
    export default {
        props: {
            postDataObj: Object,
            editMode: Boolean,
            categories: Array,
        },
        methods: {
            postData() {
                const requestOptions = {
                    method: this.editMode ? 'PUT' : 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify(this.postDataObj)
                };

                const url = this.editMode
                    ? `${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/${this.postDataObj.TodoItemID}`
                    : `${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/`;

                if (requestOptions.method == 'PUT') {
                    fetch(url, requestOptions)
                        .then(response => {
                            this.$emit('load-todo');
                            console.log('Success fetching data:', response);
                        })
                        .catch(error => {
                            console.error('Error fetching data:', error);
                        });
                } else {
                    fetch(url, requestOptions)
                        .then(response => response.json())
                        .then(data => {
                            this.$emit('init-post');
                            this.$emit('load-todo');
                            console.log('Success fetching data:', data);
                        })
                        .catch(error => {
                            console.error('Error fetching data:', error);
                        });
                }
            },
        },
    };
</script>

src/components/table.vue

template 的部分:

<template>
    <table class="table">
        <thead>
            <tr>
                <th>ID</th>
                <th>Title</th>
                <th>Description</th>
                <th>Due Date</th>
                <th>Category</th>
                <th class="text-success">
                    <span class="material-icons fs-3" style="cursor: pointer;" @click="addTodoItem()">add</span>
                </th>
            </tr>
        </thead>
        <tbody>
            <tr v-for="todo in todos" :key="todo.todoItemID" @click="editTodoItem(todo)">
                <td>{{ todo.todoItemID }}</td>
                <td>{{ todo.title }}</td>
                <td>{{ todo.description }}</td>
                <td>{{ todo.dueDate }}</td>
                <td>{{ todo.category ? todo.category.categoryName : 'N/A' }}</td>
                <td class="text-danger">
                    <button @click="deleteTodoItem(todo)" class="btn btn-danger">Delete</button>
                </td>
            </tr>
        </tbody>
    </table>
</template>

Script 的部分:

<script>
    export default {
        props: {
            todos: Array,
        },
        methods: {
            editTodoItem(todo) {
                this.$emit('edit-todo', todo);
            },
            addTodoItem() {
                this.$emit('add-todo');
            },
            deleteTodoItem(todo) {
                this.$emit('delete-todo', todo);
            },
        },
    };
</script>

並且在 vue.js 專案加入 .env

.env

VITE_API_BASE_URL="https://localhost:44396"