這篇我們要繼續把上次的前端 To do List 改成可以儲存 todos 到資料庫的樣子。
首先我們要建立一個資料庫的 table,裡面只需要 id, todos 和 userID 三個欄位:
如果不想多設一個 userID,直接用 id 來區別不同使用者的 todos 也是沒問題的。
接著我們來連線資料庫吧:
conn.php
<?php
// 連線資料庫
$server_name = 'localhost';
$username = 'example';
$password = 'example';
$db_name = 'example';
$conn = new mysqli($server_name, $username, $password, $db_name);
if ($conn->connect_error) {
die('資料庫連線錯誤' . $conn->error);
}
$conn->query('SET NAMES UTF8MB4');
$conn->query('SET time_zone = "+8:00"');
?>
連線資料庫成功之後,就可以來實作 API 了~
先來做讀取 todos 的 API
api_todos.php
如果沒有拿到 client 端 GET Request 傳過來 userID,就回傳 no todos 的 repsonse。
如果有拿到,就把這個 userID 對應的 todos 拿出來,把 userID 和 todos 都 response 給前端。
如果 userID 在資料庫裡沒有結果,也回傳錯誤並跳出程式。
<?php
require_once('conn.php');
header('Content-type:application/json;charset=utf-8');
header('Access-Control-Allow-Origin: *');
if (empty($_GET['userID'])) {
$json = [
'message' => 'no todos.'
];
$response = json_encode($json);
echo $response;
die();
}
$userID = $_GET['userID'];
$sql = "SELECT * FROM nicolakacha_todos WHERE userID=?";
$stmt = $conn->prepare($sql);
$stmt->bind_param('s', $userID);
$result = $stmt->execute();
if(!$result) {
$json = [
'ok' => false,
'message' => 'no todos.'
];
$response = json_encode($json);
echo $response;
die();
}
$result = $stmt->get_result();
$todos = [];
$row = $result->fetch_assoc();
array_push($todos, ['userID' => $row['userID'], 'todos' => $row['todos'],]);
$json = [
'ok' => true,
'todos' => $todos
];
$response = json_encode($json);
echo $response;
?>
接著是儲存 todos 的 API
如果沒有拿到 todos,就回傳 no todos 的 resposne,如果有拿到 todos,則把 todos 存進一個變數內,若存失敗一樣丟錯誤 response 給前端。
api_add_todos.php
if (empty($_POST['todos'])) {
$json = [
'ok' => false,
'message' => 'no todos.'
];
$response = json_encode($json);
echo $response;
die();
} else {
$todos = $_POST['todos'];
}
在把剛才拿到的 $todos 真正存進資料庫之前,我們還要檢查 client 端按下儲存 todos 時的狀態是已經有 userID 了,還是第一次存 todos 要讓我們給他新的 userID。
如果是第一次存,也就是我們沒有從前端拿到 userID 的情況下,我們就利用 generateID() 隨機生成一個 userID 也一起存進資料庫。
function generateID() {
$s = '';
for ($i = 1; $i <= 10; $i++) {
$s .= chr(rand(65, 90));
}
return $s;
}
並且把 todos 連同剛才的 userID 一起 INSERT 存進資料庫,並回傳儲存成功的訊息和這個新的 userID:
$userID = generateID();
$sql = "INSERT INTO nicolakacha_todos(todos, userID) VALUES (?, ?)";
$stmt = $conn->prepare($sql);
$stmt->bind_param('ss', $todos, $userID);
$result = $stmt->execute();
if ($result) {
$result = $stmt->get_result();
$json = ['ok' => true, 'userID' => $userID];
} else {
$json = ['ok' => false, 'msg' => $conn->error];
}
$response = json_encode($json);
echo $response;
如果有拿到前端 POST 過來的 userID,代表使用者只是要更新 todos 而已,我們就把 UPDATE 這筆資料,並且回傳使用者原來的 userID 和成功訊息給前端:
$userID = $_POST['userID'];
$sql = "UPDATE nicolakacha_todos SET todos = ? WHERE userID = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param('ss', $todos, $userID);
$result = $stmt->execute();
$result ? $json = [
'ok' => true,
'message' => 'Save successfully',
'userID' => $userID]
: $json = ['ok' => false, 'msg' => $conn->error];
$response = json_encode($json);
echo $response;
這樣我們儲存 todos 的 API 也完成啦:
api_add_todos.php 完整程式碼:
<?php
require_once('conn.php');
header('Content-type:application/json;charset=utf-8');
header('Access-Control-Allow-Origin: *');
function generateID() {
$s = '';
for ($i = 1; $i <= 10; $i++) {
$s .= chr(rand(65, 90));
}
return $s;
}
if (empty($_POST['todos'])) {
$json = [
'ok' => false,
'message' => 'no todos.'
];
$response = json_encode($json);
echo $response;
die();
} else {
$todos = $_POST['todos'];
}
if (!empty($_POST['userID'])) {
$userID = $_POST['userID'];
$sql = "UPDATE nicolakacha_todos SET todos = ? WHERE userID = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param('ss', $todos, $userID);
$result = $stmt->execute();
$result ? $json = [
'ok' => true,
'message' => 'Save successfully',
'userID' => $userID]
: $json = ['ok' => false, 'msg' => $conn->error];
$response = json_encode($json);
echo $response;
} else {
$userID = generateID();
$sql = "INSERT INTO nicolakacha_todos(todos, userID) VALUES (?, ?)";
$stmt = $conn->prepare($sql);
$stmt->bind_param('ss', $todos, $userID);
$result = $stmt->execute();
if ($result) {
$result = $stmt->get_result();
$json = ['ok' => true, 'userID' => $userID];
} else {
$json = ['ok' => false, 'msg' => $conn->error];
}
$response = json_encode($json);
echo $response;
}
?>
完成之後,我們一樣可以用上次做留言板時使用的 POSTMAN 來打打看 API 是否正常運作,另外有一點小提醒:別忘了要設定資料格式和跨域請求的 header 哦:
header('Content-type:application/json;charset=utf-8');
header('Access-Control-Allow-Origin: *');
前端 JavaScript 串 API
接著我們要繼續來完成前端 JavaScript (好啦,其實是 JQuery)的部份。
記得我們的功能嗎?使用者可以按儲存,把現在的 todos 都存到後端資料庫,並且拿到一個專屬的 userID,下次在網址列後面加上這個 userID 就可以訪問自己之前存的 to do list。
這邊有兩個問題要解決:
要存什麼樣的資料給後端資料庫?
要把整坨 todos 的 HTML 都塞給後端資料庫嗎?有沒有更好的做法?其實可以只存內容和完成狀態的部份吧,其他的 HTML tag 都是重複的,不是會異動的資料。
實作上就會像這樣,遍歷 HTML 之後把所有 todo 的內容都存起成物件,把完成的 todo 設為 status: 1,沒完成的設為 status: 2。
{
state: 1,
content: test test
}
接著就以 POST method 發 request 給儲存 todos 的 API:
$.ajax({
type: 'POST',
url: `${APIUrl}/api_add_todos.php`,
data: newTodos,
})
完成之後,我們要顯示 modal 來告訴使用者他的專屬 userID,並且重新導向到,網址列後面已經加上他的 userID 的 to do list,這樣他就不需要手動輸入,算是優化使用者體驗:
.done((data) => {
const userIDNumber = data.userID;
$('.save-title').text(`您好,您的 userID 是 ${userIDNumber} `);
$('.userID').text(
'保存好囉,請記下您的 userID,在網址列後面加上 ?userID={您的userID} 即可訪問個人的 Todo List~',
);
$('.understand').click(()=>{
// 重新導向到網址列後面已經加上 userID 的 to do list
document.location.href=`${location.href}?userID=${userIDNumber}`;
})
})
.fail(err => console.log(err));
到這裡,儲存 todos 並給 client 端 userID 的功能就完成啦。
怎麼拿到網址列的 userID 並把 response 回來的資料放到網頁上?
一開始不知道怎麼拿網址列的 userID,所以問了 Gooogle 大神有沒有方法可以拿到網址列上的 query string,後來查到可以透過 location.href 這個方法拿到現在的網址列,然後把 userID 的部分解析出來就好了,透過這個 function,如果網址列後面有 ?userID=xxxxx 的時候,我們可以拿到這個 xxxxx 並回傳。
function getUserID() {
const currentUrl = location.href;
if (currentUrl.indexOf('?') !== -1) {
const arr = currentUrl.split('?')[1].split('&');
for (let i = 0; i <= arr.length - 1; i += 1) {
if (arr[i].split('=')[0] === 'userID') {
let userID = arr[i].split('=')[1];
return userID;
}
}
}
}
能夠判斷有沒有 userID 之後,如果有拿到 userID,,我們就要憑這個 userID 來讀取資料庫內對應的那筆 todos :
if (userID) {
$.ajax({
type: 'GET',
url: `${APIUrl}/api_todos.php?userID=${userID}`,
})
.done((data) => {
const getTodosFromDatabase = JSON.parse(data.todos[0].todos);
getTodosFromDatabase.forEach((storedTodo) => {
let li;
if (storedTodo.state === 1) {
li = template.replace(/xxxxx/gi, storedTodo.content).replace(/className/gi, 'completed');
} else {
li = template.replace(/xxxxx/gi, storedTodo.content).replace(/className/gi, '');
}
$('.todo-list').append(li);
});
document.querySelectorAll('.todo').forEach((todo) => {
if (todo.classList.contains('completed')) {
todo.querySelector('.content').classList.add('completed-item');
todo.querySelector('.check.btn').classList.add('completed');
todo.querySelector('.check.btn').classList.add('btn-secondary');
todo.querySelector('.check.btn').textContent = '未完成';
todo.querySelector('.edit').style.display = 'none';
}
});
})
.fail(err => console.log(err));
用 GET method 發 request 給讀取內容的 API,記得把 userID 帶上網址列,
// read todo by userID
if (userID) {
$.ajax({
type: 'GET',
url: `${APIUrl}/api_todos.php?userID=${userID}`,
})
.done()
.fail();
}
.done() 要做的事就是把 todos 讀出來,而 .fail() 一樣是用來處理錯誤。
使用 todo 的模板,把 todo 的內容替換掉,也依 status 是 1 或 2,加上不同的 class,最後再 append 回我們的 .todo-list 區塊。
除了放到頁面上,如果 todo 是完成狀態,也要記得變成完成的樣子:
- 把未完成按鈕變成已完成
- 把已完成按鈕加上已完成的 class
- 把已完成按鈕的顏色改掉
- 把 todo 的內容加上已完成的 class,然後透過 CSS 把它劃掉
- 隱藏編輯按鈕
.done((data) => {
const getTodosFromDatabase = JSON.parse(data.todos[0].todos);
getTodosFromDatabase.forEach((storedTodo) => {
let li;
if (storedTodo.state === 1) {
li = template.replace(/xxxxx/gi, storedTodo.content).replace(/className/gi, 'completed');
} else {
li = template.replace(/xxxxx/gi, storedTodo.content).replace(/className/gi, '');
}
$('.todo-list').append(li);
});
document.querySelectorAll('.todo').forEach((todo) => {
if (todo.classList.contains('completed')) {
todo.querySelector('.content').classList.add('completed-item');
todo.querySelector('.check.btn').classList.add('completed');
todo.querySelector('.check.btn').classList.add('btn-secondary');
todo.querySelector('.check.btn').textContent = '未完成';
todo.querySelector('.edit').style.display = 'none';
}
});
})
.fail(err => console.log(err));
到這裡,我們 JavaScript 串接 API 的部份也完成了,以下是 script.js 完整的程式碼:
const APIUrl = 'http://localhost:80/nicolas_php_projects/week12_hw2';
const template = `
<li class="className todo list-group-item d-flex justify-content-between align-items-center">
<div class="todo-content">
<span class="content">xxxxx</span>
<input type="text" class="d-none">
</div>
<div class="functional-btn">
<button class="check btn btn-primary">已完成</button>
<button class="edit btn btn-success" data-toggle="modal" data-target="#edit-content">編輯</button>
<button class="delete btn btn-danger">刪除</button>
</div>
</li>`;
const reminder = `
<div class="reminder alert alert-danger text-center" role="alert">
請輸入資料哦!
</div>`;
// get the userID from query parameter
function getUserID() {
const currentUrl = location.href;
if (currentUrl.indexOf('?') !== -1) {
const arr = currentUrl.split('?')[1].split('&');
for (let i = 0; i <= arr.length - 1; i += 1) {
if (arr[i].split('=')[0] === 'userID') {
let userID = arr[i].split('=')[1];
return userID;
}
}
}
}
// encode input to avoid xss attack
function encodeHTML(s) {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
}
$(document).ready(() => {
const userID = getUserID();
// add new todo
$('.submit').click(() => {
const value = encodeHTML($('.todo-input').val());
const newTodo = template.replace(/xxxxx/gi, value).replace(/className/gi, 'ongoing');
$('.todo-input').val('');
if (value.trim() !== '') {
$('.reminder').remove();
$('.todo-list').prepend(newTodo);
} else {
$('.main').prepend(reminder);
}
});
// delete todo
$('.todo-list').on('click', '.delete.btn', (e) => {
$(e.target).parent().parent().remove();
});
// complete todo
$('.todo-list').on('click', '.check.btn', (e) => {
const btn = $(e.target);
const todoForCheck = $(e.target).parent().parent();
// 變成沒完成
if (btn.hasClass('completed')) {
todoForCheck.removeClass('completed');
todoForCheck.find('.content').removeClass('completed-item');
btn.removeClass('completed');
btn.removeClass('btn-secondary').addClass('btn-primary');
btn.text('已完成');
btn.parent().find('.edit').show();
// 變成已完成
} else {
todoForCheck.addClass('completed');
todoForCheck.find('.content').addClass('completed-item');
btn.addClass('completed');
btn.removeClass('btn-primary').addClass('btn-secondary');
btn.text('未完成');
btn.parent().find('.edit').hide();
}
});
// filter to do
$('.main').on('click', '.all', () => {
$('.todo').addClass('d-flex').show();
});
// filter to do
$('.main').on('click', '.filter-ongoing', () => {
$('.todo.completed').removeClass('d-flex').hide();
$('.todo:not(.completed)').addClass('d-flex').show();
});
// filter to do
$('.main').on('click', '.filter-completed', () => {
$('.todo').removeClass('d-flex').hide();
$('.todo.completed').addClass('d-flex').show();
});
// edit to do
$('.main').on('click', '.edit', (e) => {
const originTodo = $(e.target).parent().parent().find('.content').text();
$('.edit-todo-input').val(originTodo);
$('.confirm').click(()=> {
let newTodo = $('.edit-todo-input').val();
$(e.target).parent().parent().find('.content').text(newTodo);
$('.confirm').off();
})
});
// Save todo to database
$(document).on('click', '.save', () => {
const clientTodoList = [];
const oldTodos = document.querySelectorAll('.todo');
oldTodos.forEach((todo) => {
const clientTodo = {};
if (todo.classList.contains('completed')) {
clientTodo.state = 1;
} else {
clientTodo.state = 2;
}
clientTodo.content = todo.querySelector('.content').textContent;
clientTodoList.push(clientTodo);
});
const x = JSON.stringify(clientTodoList);
const newTodos = { userID: userID, todos: x };
console.log(newTodos);
$.ajax({
type: 'POST',
url: `${APIUrl}/api_add_todos.php`,
data: newTodos,
})
.done((data) => {
const userIDNumber = data.userID;
$('.save-title').text(`您好,您的 userID 是 ${userIDNumber} `);
$('.userID').text(
'保存好囉,請記下您的 userID,在網址列後面加上 ?userID={您的userID} 即可訪問個人的 Todo List~',
);
$('.understand').click(()=>{
document.location.href=`${location.href}?userID=${userIDNumber}`;
})
})
.fail(err => console.log(err));
});
// read todo by userID
if (userID) {
$.ajax({
type: 'GET',
url: `${APIUrl}/api_todos.php?userID=${userID}`,
})
.done((data) => {
const getTodosFromDatabase = JSON.parse(data.todos[0].todos);
getTodosFromDatabase.forEach((storedTodo) => {
let li;
if (storedTodo.state === 1) {
li = template.replace(/xxxxx/gi, storedTodo.content).replace(/className/gi, 'completed');
} else {
li = template.replace(/xxxxx/gi, storedTodo.content).replace(/className/gi, '');
}
$('.todo-list').append(li);
});
document.querySelectorAll('.todo').forEach((todo) => {
if (todo.classList.contains('completed')) {
todo.querySelector('.content').classList.add('completed-item');
todo.querySelector('.check.btn').classList.add('completed');
todo.querySelector('.check.btn').classList.add('btn-secondary');
todo.querySelector('.check.btn').textContent = '未完成';
todo.querySelector('.edit').style.display = 'none';
}
});
})
.fail(err => console.log(err));
}
});
總結
呼,終於做完了!有了上次做留言板 SPA 的經驗應該這次做 SPA To do list 會順很多,其實做法很類似,要注意的地方就是思考怎麼存資料和讀資料,也順便練習了用 JQuery 操控 DOM 來做 To do list 的新增、刪除、編輯、和篩選功能,算是一個前後端都有練習到吧~