實作 To do List SPA(下):後端及 API 串接部份


Posted by Nicolakacha on 2020-09-12

這篇我們要繼續把上次的前端 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 是完成狀態,也要記得變成完成的樣子:

  1. 把未完成按鈕變成已完成
  2. 把已完成按鈕加上已完成的 class
  3. 把已完成按鈕的顏色改掉
  4. 把 todo 的內容加上已完成的 class,然後透過 CSS 把它劃掉
  5. 隱藏編輯按鈕
 .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, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
}

$(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 的新增、刪除、編輯、和篩選功能,算是一個前後端都有練習到吧~

完成的 To do List DEMO


#to do list #API #PHP #jquery







Related Posts

翻譯 API

翻譯 API

簡明 C 語言入門教學

簡明 C 語言入門教學

使用 ROS 與 Gazebo 模擬一個自動避障機器人

使用 ROS 與 Gazebo 模擬一個自動避障機器人


Comments