Boards List
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”frontend/src/pages/index.astro — หน้า landing ที่ห่อด้วย Base <script> ฝั่ง client เช็ค getToken() ก่อนสิ่งอื่นใด แล้ว redirect ไปที่ /login ถ้าไม่มี ไม่งั้นก็เรียก apiFetch<Board[]>('/boards') (boards) render แต่ละบอร์ดเป็น link ไปที่ /boards/:id และต่อฟอร์มเล็ก ๆ ที่ POST {title} ไปที่ /boards แล้วเอาบอร์ดที่เพิ่งสร้างต่อเข้า list โดยไม่ต้อง refetch ใหม่ทั้งหมด
/boards/:id — บอร์ดแบบ interactive จริง ๆ ที่มี column กับ card — เป็นหน้าที่ของ Module 9 ส่วน link ที่ชี้ไปในบทนี้เป็น <a href> ธรรมดาแบบ static คลิกแล้วก็แค่ navigate แบบปกติของ browser ไปยังหน้าที่ยังไม่มีอยู่ แล้วได้ 404 จนกว่า Module 9 (The Kanban Island) จะสร้างหน้านั้นขึ้นมา นั่นคือสิ่งที่คาดไว้อยู่แล้วตอนนี้ในคอร์ส ไม่ใช่บั๊กของบทนี้
auth guard รันฝั่ง client เป็นอย่างแรกใน script เพราะ static site ไม่มี server hop ต่อ request ให้ดัก — ไม่มีชั้น middleware ที่จะเช็ค cookie หรือ header ได้ก่อนที่จะเสิร์ฟ HTML ของหน้าออกไป แบบที่แอป server-rendered ทำได้ getToken() (สร้างใน auth-pages อธิบายใน api-client) ที่คืน null หมายถึง redirect ทันที รูปแบบ “เช็คก่อน แล้วค่อยทำตามผล” แบบเดียวกับที่ nav script ของ shell-layout ใช้ไปแล้วสำหรับการตัดสินใจที่เล็กกว่า — จะเปิด nav สถานะไหน
การเอาบอร์ดที่เพิ่งสร้างต่อเข้า list ตรง ๆ แทนที่จะเรียก apiFetch<Board[]>('/boards') ใหม่หลัง POST สำเร็จ ไม่ใช่ micro-optimization แต่คือการใช้ข้อมูลที่อยู่ในมืออยู่แล้ว POST /boards (boards) คืน Board ที่สร้างเสร็จมาใน response body เอง object นั้นมีทุก field ที่ renderBoard ต้องใช้ ดังนั้นการสร้าง <li> หนึ่งอันจากข้อมูลชุดนั้นคือ DOM operation เดียวที่ใช้ response ที่หน้านั้นได้รับมาแล้วซ้ำ แทนที่จะยิง network request รอบสองแค่เพื่อได้ list กลับมาที่ตอนนี้มีสิ่งที่หน้ารู้อยู่แล้วรวมอยู่ด้วย
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”หน้า Astro แบบ static + vanilla script (ตัวที่เราใช้) เทียบกับสร้างหน้านี้เป็น island แบบ Preact/React (client:load)
นี่คือ trade-off เดียวกับที่ Pros & cons ของ shell-layout เปิดไว้ แค่เอามาใช้กับหน้าที่ทำมากกว่า nav toggle นิดหน่อย: ดึง list, render ออกมา, และจัดการ form submission หนึ่งอัน
- ข้อดี:
index.astroไม่ส่ง framework runtime ไปเลยสักไบต์แค่เพื่อ loop array แล้วเรียกdocument.createElement('li')สองสามครั้ง — vanilla DOM API จัดการ “render N รายการ แล้วต่อรายการใหม่เป็นครั้งคราว” ได้โดยไม่ต้องมี virtual DOM, ไม่ต้อง diffing, ไม่ต้องเลือก hydration directive สักตัว เหตุผลเดียวกับที่ทำให้login.astroกับregister.astroไม่มี framework: ปริมาณ state ตรงนี้ — list ของบอร์ด, error message ของฟอร์มเดียว — ไม่คุ้มกับต้นทุน runtime ของ component tree - ข้อเสีย:
renderBoardที่สร้าง DOM node เองด้วยมือ (document.createElement,.append) และloadBoardsที่ track attributehiddenของ<p>สถานะ “ว่าง” เองด้วยมือ เป็น imperative list-management code แบบที่หยุด scale ทันทีที่ item ต้องถูกจัดลำดับใหม่ กรอง หรืออัปเดตจากมากกว่าหนึ่งที่พร้อมกัน นั่นคือสิ่งที่บอร์ดจริงของ Module 9 ต้องการเป๊ะ ๆ — การ์ดถูกลากข้าม column, การจัดลำดับใหม่แบบ optimistic ที่ต้อง roll back ตอน server ปฏิเสธ, และ eventcard.moved/card.createdจากที่อื่น (protocol) ที่มาทาง WebSocket ได้ทุกเมื่อ ทั้งหมดต้องอยู่ sync กับสิ่งที่อยู่บนหน้าจอ list บอร์ดของหน้านี้อ่านเป็นหลักและโตแบบ append-only เท่านั้น — บอร์ดใหม่มาทีละอันจาก form submission ของหน้านี้เอง ไม่เคยมาจากที่อื่นเลย ที่เป็นเพดานพอดีที่ vanilla script ยังเป็นเครื่องมือที่ถูกต้องอยู่ Module 9 คือจุดที่เพดานนั้นถูกข้ามไป
ลงมือสร้าง
หัวข้อที่มีชื่อว่า “ลงมือสร้าง”frontend/src/pages/index.astro
หัวข้อที่มีชื่อว่า “frontend/src/pages/index.astro”---import Base from '../layouts/Base.astro';---<Base title="Boards"> <h1>Your boards</h1> <ul id="board-list"></ul> <p id="board-list-empty" hidden>No boards yet — create your first one below.</p>
<form id="create-board-form"> <label> New board title <input type="text" name="title" required /> </label> <button type="submit">Create board</button> </form> <p id="create-board-error" role="alert" hidden></p></Base>
<script> import { apiFetch, getToken, ApiError, type Board } from '../lib/api';
const listEl = document.getElementById('board-list') as HTMLUListElement; const emptyEl = document.getElementById('board-list-empty') as HTMLParagraphElement; const form = document.getElementById('create-board-form') as HTMLFormElement; const errorEl = document.getElementById('create-board-error') as HTMLParagraphElement;
function renderBoard(board: Board): void { const item = document.createElement('li'); const link = document.createElement('a'); link.href = `/boards/${board.id}`; link.textContent = board.title; item.append(link); listEl.append(item); emptyEl.hidden = true; }
async function loadBoards(): Promise<void> { try { const boards = await apiFetch<Board[]>('/boards'); if (boards.length === 0) { emptyEl.hidden = false; } else { boards.forEach(renderBoard); } } catch (err) { if (err instanceof ApiError && err.status === 401) { window.location.href = '/login'; return; } throw err; } }
form.addEventListener('submit', async (event) => { event.preventDefault(); errorEl.hidden = true;
const data = new FormData(form); const title = data.get('title') as string;
try { const board = await apiFetch<Board>('/boards', { method: 'POST', body: JSON.stringify({ title }), }); renderBoard(board); form.reset(); } catch (err) { errorEl.textContent = err instanceof ApiError ? err.message : 'Something went wrong. Try again.'; errorEl.hidden = false; } });
if (!getToken()) { window.location.href = '/login'; } else { void loadBoards(); }</script>ทุกอย่างที่ต้องใช้ DOM reference กับ event listener ถูกต่อสายไว้ก่อน — listEl, emptyEl, form, errorEl, renderBoard, loadBoards, และ submit listener ของฟอร์ม ล้วนมีอยู่แล้วก่อนที่ script จะตัดสินใจว่าจะ redirect หรือ fetch guard รันเป็นตัวสุดท้ายโดยตั้งใจ: if (!getToken()) ที่ redirect คือ branch เดียวที่จบประโยชน์ของหน้าไปเลย จึงอ่านเหมือนการตัดสินใจสุดท้ายที่ script ทำ ไม่ใช่ประตูที่ต้องร้อยผ่านทุกอย่างด้านบน
void loadBoards() — operator void ไม่ใช่การเรียก loadBoards() เฉย ๆ — บอกชัดเจนว่า promise ที่คืนมาตั้งใจไม่ await ที่ระดับบนสุดของ script (ไม่มีอะไรให้ await เข้าไป; script เองไม่ใช่ async function) และปิดเสียงเตือน “floating promise” ที่ lint config ที่เข้มงวดกว่าจะเตือน loadBoards ยังจัดการ error ของตัวเองด้วย try/catch อยู่ภายใน ดังนั้นไม่มีอะไรถูกละเลยจริง ๆ — void คือเอกสารกำกับ ไม่ใช่ทางลัดเลี่ยง error handling
import { apiFetch, getToken, ApiError, type Board } from '../lib/api'; ผสม type-only import (type Board) เข้าไปใน statement เดียวกับ value import อีกสามตัว — เป็น TypeScript ที่ถูกต้องตั้งแต่เวอร์ชัน 4.5 และหมายความว่า bundler รู้ได้ทันทีว่า Board ไม่ได้มีส่วนกับ runtime output เลย มีผลแค่กับการ type-check เท่านั้น
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”cd frontendnpm run devตอนที่ยัง log out อยู่ (ล้าง localStorage ใน devtools ถ้าจำเป็น) เข้า / แล้วยืนยันว่าเด้ง redirect ไปที่ /login ทันที จากนั้น login (จาก auth-pages) แล้วกลับมาลงที่ / ยืนยันว่าโชว์ “No boards yet” สำหรับบัญชีใหม่ สร้างบอร์ดผ่านฟอร์ม — บอร์ดควรปรากฏใน list ทันที โดยไม่ต้อง reload หน้า และข้อความสถานะว่างควรหายไป reload หน้าแล้วยืนยันว่าบอร์ดยังอยู่ (apiFetch<Board[]>('/boards') ดึงจาก Postgres ใหม่ ไม่ใช่จากอะไรที่ cache ไว้ฝั่ง client) คลิก link ของบอร์ดแล้วยืนยันว่าได้ 404 — เป็นเรื่องที่คาดไว้อยู่แล้ว เพราะ /boards/:id ยังไม่มีอยู่จนกว่าจะถึง Module 9 (The Kanban Island)
คุณสร้าง index.astro: auth guard ฝั่ง client ที่ redirect ไปที่ /login ก่อนจะ render อะไรที่มีความหมาย, การ fetch GET /boards ที่ render เป็น list ธรรมดา, และฟอร์ม create-board ที่เอา response ของตัวเองต่อเข้า list แทนที่จะ refetch ใหม่ คุณเห็นแล้วว่าทำไมหน้านี้ — อ่านเป็นหลัก, append-only, ฟอร์มเดียว — ถึงยังอยู่ฝั่ง vanilla script ของเส้นที่ shell-layout ขีดไว้ และคุณสมบัติไหนของบอร์ด Kanban จริง (concurrent drag state, optimistic update, remote WebSocket event) ที่ผลัก Module 9 ให้ข้ามเส้นนั้นไป Module 8 เสร็จแล้ว — ทุกหน้า static ที่ frontend ของ TaskFlow ต้องการ ตั้งแต่ nav bar ไปจนถึง board list ต่อเข้ากับ apiFetch client แบบมี type ที่โมดูลนี้สร้างไว้ครบแล้ว ต่อไป Module 9 — The Kanban Island — เริ่มต้น: หน้าเดียวใน frontend นี้ที่คุ้มค่าจะใช้ Preact island และสร้าง /boards/:id ขึ้นมาจริง ๆ