🔁 ทวนก่อนเริ่ม — Lab นี้ใช้คำพวกนี้ตลอด ถ้าข้อไหนไม่คุ้นให้กลับไปบทนั้นก่อน
- partial = แม่พิมพ์ตักข้าวที่ใช้ซ้ำได้ (ไฟล์ขึ้นต้นด้วย
_) · locals = ของที่ยื่นใส่มือแม่พิมพ์ชัด ๆ ·render @posts= "ใช้แม่พิมพ์_postกับทุกชิ้น" (บท 123) - helper = เครื่องมือจัดจาน เช่น
truncate(ตัดข้อความยาว) ·time_ago_in_words(บอกว่าผ่านมานานแค่ไหน) ·form_with(ฟอร์มที่ตั้งชื่อช่องให้เอง) (บท 124) - XSS = ลูกค้าแอบเขียนคำสั่งมาในข้อความ ·
<%= %>ใส่ทุกอย่างในซองใสให้เอง ·raw/html_safe= แกะซองออก (บท 125) - N+1 = เดินไปตู้ทีละแผ่นหลายสิบรอบ ·
includes= ใบเบิกทีเดียวได้ทั้งตะกร้า · นับจำนวนครั้งจาก log (บท 114 · บท 125) - scope
recent= ชื่อเล่นของ "เรียงจากใหม่ไปเก่า" ที่มีอยู่แล้วในapp/models/post.rb· relation ยังไม่ไปตู้จนกว่าจะใช้ข้อมูลจริง (บท 113)
🍜 เริ่มจากโค้ดสถานะไหน: โปรเจกต์ wongnok-blog-all-in-one ตอนจบบท 120
(หมวด 5 · บทความ 30 จาก seeds) · บท 121–125 ที่ให้ทดลองแก้ ต้อง git restore ไปแล้ว ·
จบบทนี้โค้ดของคุณจะตรงกับ "จุดเช็คพอยต์ A" ที่บทถัดไป (127–129) ใช้ต่อ
🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 · pagy 43.8.1 · bullet 8.3.1 (สไลด์กับ repo ของอาจารย์เขียน Ruby 4.0.5 · Rails 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน)
ผลรันทุกก้อนในบทนี้รันจริงแล้วก็อปมา · ค่าที่เปลี่ยนทุกครั้ง เช่น เวลา ms · เลข id · วันเวลา · เลข commit · "1 day ago"
ของคุณจะต่าง (seed ตั้งวันที่นับจากวันที่คุณรัน) · log ตัดรหัสสีออกแล้ว · path ยาว ๆ ย่อเป็น …
แผนที่ของ Lab: ตอนนี้มีอะไร จบแล้วจะมีอะไร
สไลด์หน้า 20 สรุปไว้สองฝั่ง · ฝั่งซ้ายคือหน้าที่ scaffold ให้มา (ใช้ได้ แต่คนอ่านไม่รู้เรื่อง) · ฝั่งขวาคือเป้าหมายของบทนี้
ตอนนี้ (scaffold ดิบ)
หน้า /posts โชว์ Title: Description: Category: 1 เรียงกันทุกชิ้น ·
ข้อความยาวโชว์เต็ม · 30 ชิ้นอยู่หน้าเดียว · หน้า show มีหัวข้อว่า "Showing post"
จบ Lab (จุดเช็คพอยต์ A)
หน้า index เป็นรายการอ่านง่าย พร้อมชื่อหมวด · ตัดข้อความเหลือ ~80 ตัวอักษร · "2 days ago" · หน้าละ 10 ชิ้นมีปุ่มเปลี่ยนหน้า ·
หน้า show มีทางกลับ (บทความทั้งหมด › หมวด › หัวข้อ) · partial _post ตัวเดียวใช้ทั้งสองหน้า
ทำไมต้องมีบทความถึง 30 ชิ้น (ที่เราใส่ไว้ตั้งแต่บท 120): สไลด์หน้า 20 บอกว่าปัญหาอย่าง N+1 หรือการแบ่งหน้าไม่โผล่ตอนมีข้อมูล 3 แถว
· ใน Lab นี้คุณจะเห็นเองว่าถ้าลืม includes จำนวน query กระโดดจาก 3 เป็น 12 — กับข้อมูล 3 แถว มันต่างกันนิดเดียวจนไม่มีใครสังเกต
ขั้น 0 — เตรียมร้านก่อนเริ่ม
เปิด terminal สองแท็บในโฟลเดอร์โปรเจกต์ · แท็บแรกเปิดร้าน แท็บที่สองไว้พิมพ์คำสั่ง
# แท็บที่ 1 — เปิดร้านพร้อมตัว build CSS (บท 120 · ละเอียดในบท 128)
$ bin/dev
# แท็บที่ 2 — ต้องไม่ขึ้นอะไรเลย = ไม่มีของค้างจากบท 121–125
$ git status --short
ทำไมต้องสะอาด: ตอนท้าย Lab เราจะ git commit ทั้งหมดเป็นก้อนเดียว · ถ้ามีของทดลองค้างจากบทก่อนจะปนเข้าไปด้วย
· ถ้ายังมีของค้าง ให้ git restore . (คืนไฟล์ที่แก้) แล้ว git status --short อีกรอบ
เปิด http://localhost:3000/posts ดูของเดิมก่อน · แต่ละชิ้นจะมีบรรทัด Category: ตามด้วยเลข เช่น 1
· แล้วดู log ของ request นั้น (ไฟล์ log/development.log หรือหน้าจอแท็บที่ 1) — ผลจริง ตัดมาแค่บรรทัดที่ไปตู้:
Post Load (2.4ms) SELECT "posts".* FROM "posts" /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
อ่านบรรทัดนี้: Post Load = "ไปหยิบบทความจากตู้" · (2.4ms) = ใช้เวลาเท่าไหร่ (ของคุณจะต่าง) ·
ก้อน /*action='index',…*/ ท้ายบรรทัดคือคอมเมนต์ที่ Rails แปะบอกว่า SQL นี้มาจาก action ไหน — ไม่ต้องสนใจ ต่อจากนี้จะย่อเป็น /*…*/
ไม่มี LIMIT = หยิบทุกแฟ้มในลิ้นชัก มาจัดจานพร้อมกัน · ตอนนี้ 30 ชิ้นยังไม่เป็นไร แต่ถ้าวันหนึ่งมี 30,000 ชิ้น หน้านี้จะช้าจนใช้ไม่ได้
— นี่คือกฎข้อ 6 ใน prompts/00-context.md ("index action ต้องมี limit หรือ pagination") ที่ scaffold ไม่รู้ (บท 121)
ขั้น 1 — ติดตั้ง pagy: ตัวช่วยแบ่งรายการเป็นหน้า ๆ
pagination คือการแบ่งรายการยาว ๆ เป็นหน้า ๆ — หน้า 1 โชว์ชิ้นที่ 1–10 · หน้า 2 โชว์ 11–20 · มีปุ่ม "ถัดไป / ก่อนหน้า"
· ใน SQL ทำด้วยสองคำ: LIMIT 10 (เอาแค่ 10 แผ่น) กับ OFFSET 10 (ข้าม 10 แผ่นแรกก่อน)
แล้วทำไมไม่เขียน Post.limit(10).offset(...) เอง? เพราะของที่ต้องคิดเพิ่มเยอะกว่าที่เห็น:
ผู้ใช้พิมพ์ ?page=0 · ?page=abc · ?page=99 มาจะเป็นยังไง · ทั้งหมดมีกี่หน้า · หน้าสุดท้ายต้องซ่อนปุ่ม "ถัดไป" · ลิงก์ของแต่ละปุ่มต้องเป็น URL อะไร
· สไลด์หน้า 22 จัด "ทำ pagination เองด้วย offset แทนที่จะใช้ gem" ไว้ในรายการที่ AI พลาดประจำ
gem คือแพ็กเกจโค้ดสำเร็จรูปของ Ruby ที่คนอื่นเขียนและทดสอบมาแล้ว (เหมือน module ของ Go ที่ go get มา) · ตัวที่เราใช้ชื่อ pagy
ลิ้นชัก posts มีแฟ้ม 30 แผ่น · ถ้าหยิบมาวางบนจานทีเดียวทั้งหมด จานล้น ลูกค้าอ่านไม่ไหว (และถ้ามี 30,000 แผ่น คนหยิบก็หมดแรง) · pagy คือผู้ช่วยที่หยิบทีละปึก: ก่อนอื่นนับว่ามีกี่แผ่น (จะได้บอกว่า "มีทั้งหมด 3 ปึก") แล้วหยิบมาเฉพาะปึกที่ลูกค้าขอ ปึกละ 10 แผ่น — เหมือนผลค้นหาสินค้าในแอปช้อปปิ้งที่มีเลขหน้า 1 2 3 ด้านล่าง · ลูกค้าขอปึกที่ 99 ก็ไม่โวยวาย แค่ให้จานว่างพร้อมบอกทางกลับ
ที่ไม่เหมือนกัน: คนจริงนับครั้งเดียวแล้วจำได้ · pagy ไม่จำอะไรข้ามคำขอเลย — ทุกครั้งที่ลูกค้าเปิดหน้าใหม่ มันไปนับใหม่ + หยิบใหม่จากตู้
(นั่นคือที่มาของ query COUNT ที่จะเห็นใน log ทุกหน้า)
สั่งติดตั้ง (ผลจริง):
$ bundle add pagy --version "~> 43.5"
Fetching gem metadata from https://rubygems.org/........
Resolving dependencies...
bundle add pagy— "เพิ่ม gem ชื่อ pagy ลงโปรเจกต์" · มันเขียนบรรทัดใหม่ลงGemfileแล้วติดตั้งให้--version "~> 43.5"— ขอรุ่น 43.5 ขึ้นไป แต่ไม่ข้ามไป 44 ·~>อ่านว่า "ประมาณ" (ใน Go คือgo.modที่ล็อกรุ่นหลักไว้)- เครื่องผมมี pagy อยู่แล้วเลยขึ้นแค่สองบรรทัด · เครื่องที่ยังไม่เคยมีจะเห็นบรรทัดดาวน์โหลดและติดตั้งเพิ่ม — ปกติ
ดูสิ่งที่เปลี่ยน — ท้าย Gemfile มีสองบรรทัดใหม่ (บรรทัดว่าง + บรรทัดนี้):
gem "pagy", "~> 43.5"
และ Gemfile.lock (สมุดจดว่า "ติดตั้งรุ่นไหนไปจริง ๆ") ได้ pagy (43.8.1) กับ yaml (0.4.0) ที่ pagy ต้องใช้
· ถ้าของคุณได้ 43.x ที่ใหม่กว่า ไม่เป็นไร โค้ดในบทนี้ใช้ได้เหมือนกัน
⚠️ ติดตั้ง gem แล้วต้องปิดร้านแล้วเปิดใหม่ — ไปแท็บที่ 1 กด Ctrl+C แล้ว bin/dev อีกรอบ ·
Rails โหลด gem แค่ตอนเปิดร้าน · ถ้าไม่ restart ขั้นต่อไปจะเจอหน้าแดง NameError (uninitialized constant ApplicationController::Pagy) (ผลจริง)
- gem · Gemfile · Gemfile.lock ·
bundle add - gem = แพ็กเกจของ Ruby · Gemfile = รายการ gem ที่โปรเจกต์ขอ · Gemfile.lock = รุ่นที่ติดตั้งจริง (commit ทั้งสองไฟล์เสมอ) · Bundler = ตัวจัดการ gem · ถาม senior ได้ว่า "ทีมนี้มี gem สำหรับ pagination ที่ใช้เป็นมาตรฐานไหม หรือให้ใช้ pagy"
- pagination · limit · offset · paginator
- pagination = แบ่งรายการเป็นหน้า · limit = จำนวนต่อหน้า · offset = ข้ามกี่แถว · paginator = ตัวที่คิดเรื่องพวกนี้ให้ · พิมพ์บอก AI ได้ว่า "paginate the posts index with pagy 43 (include Pagy::Method, limit: 10) — do not hand-roll offset"
ขั้น 2 — controller: หยิบทีละ 10 ชิ้น เรียงใหม่ไปเก่า พร้อมหมวด
ก่อนใช้ pagy(...) ใน controller ได้ ต้อง "สอน" controller ให้รู้จักมันก่อน · ทำที่
app/controllers/application_controller.rb (แม่ของทุก controller — ใส่ที่นี่ทีเดียว ทุกแผนกได้ใช้) · ไฟล์ทั้งไฟล์หลังแก้:
class ApplicationController < ActionController::Base
include Pagy::Method
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
# Changes to the importmap will invalidate the etag for HTML responses
stale_when_importmap_changes
end
- เพิ่มแค่บรรทัดที่ 2 กับบรรทัดว่างใต้มัน ·
include Pagy::Method= "เอาความสามารถใน modulePagy::Methodมาใส่ class นี้" (module ของ Ruby · บท 95) → ทุก controller เรียกpagy(...)ได้ - บรรทัด 4–8 เป็นของเดิมที่
rails newใส่ไว้ ไม่ต้องแตะ ·allow_browser= รับเฉพาะเบราว์เซอร์รุ่นใหม่ ·stale_when_importmap_changesเกี่ยวกับไฟล์ JavaScript (เรื่อง importmap อยู่บท 127)
⚠️ tutorial บนเน็ตส่วนใหญ่เขียนสำหรับ pagy รุ่นเก่า — ถ้าเจอ include Pagy::Backend อย่าพิมพ์ตาม · รุ่น 43 ไม่มีแล้ว
ผลจริงเมื่อลองใส่: หน้าแดง NameError (uninitialized constant Pagy::Backend) ·
ส่วน items: 10 (ชื่อเก่าของ limit:) แย่กว่า — ไม่ error เลย แค่เงียบ ๆ ได้หน้าละ 20 ชิ้น "หน้า 1 / 2" (ผลจริง)
ต่อไปที่ app/controllers/posts_controller.rb · ใน action index ของเดิมมีบรรทัดเดียวคือ @posts = Post.all · เปลี่ยนเป็นแบบนี้:
# GET /posts or /posts.json
def index
@pagy, @posts = pagy(Post.includes(:category).recent, limit: 10)
end
อ่านจากในออกนอก:
Post.includes(:category)— "ตอนหยิบบทความ หยิบหมวดของมันมาทั้งตะกร้าด้วย" (บท 114) · ต้องมีเพราะขั้น 3 เราจะโชว์ชื่อหมวด.recent— scope ที่มีอยู่แล้วใน model:scope :recent, -> { order(created_at: :desc) }= เรียงจากใหม่ไปเก่า (-> { }= lambda ก้อนโค้ดที่เก็บไว้รันทีหลัง · บท 115)pagy(…, limit: 10)— ส่งใบเบิก (ที่ยังไม่ยื่น) ให้ pagy · pagy อ่าน?page=จาก URL เอง แล้วคืนของสองชิ้น: ข้อมูลของการแบ่งหน้า (หน้าไหน · ทั้งหมดกี่หน้า) กับบทความ 10 ชิ้นของหน้านั้น@pagy, @posts = …— Ruby รับของสองชิ้นใส่สองตัวแปรในบรรทัดเดียว (เหมือนa, b := f()ของ Go) · ทั้งคู่ขึ้นต้นด้วย@= วางบนถาดหน้าครัวให้ view หยิบ
ในไฟล์เดียวกันเปลี่ยนข้อความแจ้งเตือนสามจุดเป็นภาษาไทยด้วย (ผู้ใช้เห็น) — เปลี่ยนแค่ข้อความในเครื่องหมายคำพูด:
format.html { redirect_to @post, notice: "สร้างบทความแล้ว" }
…
format.html { redirect_to @post, notice: "บันทึกการแก้ไขแล้ว", status: :see_other }
…
format.html { redirect_to posts_path, notice: "ลบบทความแล้ว", status: :see_other }
(ของเดิมคือ "Post was successfully created." · "…updated." · "…destroyed." ใน action create · update · destroy)
รีเฟรช /posts — หน้าตายังเหมือนเดิม แต่เหลือ 10 ชิ้น · ดู log (ผลจริง ตัดบรรทัดประกอบที่ยังไม่ต้องดูออก — ขั้น 4 จะโชว์ log เต็มทั้งก้อน):
Post Count (0.7ms) SELECT COUNT(*) FROM "posts" /*…*/
Post Exists? (0.8ms) SELECT 1 AS one FROM "posts" LIMIT 1 OFFSET 0 /*…*/
Post Load (0.8ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT 10 OFFSET 0 /*…*/
Category Load (0.7ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (5, 4, 3, 2, 1) /*…*/
Completed 200 OK in 50ms (Views: 23.2ms | ActiveRecord: 7.7ms (4 queries, 0 cached) | GC: 8.5ms)
AVOID eager loading detected
Post => [:category]
Remove from your query: .includes([:category])
- บรรทัด
Completedคือบรรทัดที่ต้องหาเสมอ ·200 OK= เสิร์ฟสำเร็จ ·(4 queries, 0 cached)= ไปตู้ 4 ครั้ง (cached อธิบายตอนตรวจรับ) · ตัวเลข ms ของคุณจะต่าง Post Count— pagy นับก่อนว่ามีกี่ชิ้น (จะได้รู้ว่ามี 3 หน้า)Post Exists?— มาจากif @posts.any?ใน index เดิมของ scaffold ("มีสักชิ้นไหม") · ขั้น 4 จะเอาออกPost Load … LIMIT 10 OFFSET 0— หยิบ 10 แผ่นแรก เรียงใหม่ไปเก่า ·Category Load … IN (…)— ใบเบิกทีเดียวของincludes- AVOID eager loading — gem bullet (ตัวจับ N+1 ที่เปิดไว้ในโปรเจกต์ · บท 125) บอกว่า "หยิบหมวดมาแต่ไม่ได้ใช้ เอา includes ออกได้" (eager loading = ชื่อทางการของการหยิบล่วงหน้าแบบ
includes) — ถูกของมัน เพราะ partial เดิมยังโชว์เลขcategory_id· ขั้นถัดไปเราจะใช้ชื่อหมวด แล้วคำเตือนนี้จะหายเอง
ขั้น 3 — partial _post: แม่พิมพ์ที่ใช้ได้ทั้งหน้ารายการและหน้าเต็ม
เขียนทับ app/views/posts/_post.html.erb ทั้งไฟล์ · partial นี้จะถูกใช้สองแบบ: แบบย่อในหน้ารายการ (หัวข้อเป็นลิงก์ · ข้อความตัดสั้น)
กับแบบเต็มในหน้า show (หัวข้อใหญ่ · ข้อความเต็ม) · สลับด้วยสวิตช์ full
<%# locals: (post:, full: false) -%>
<article id="<%= dom_id post %>" class="py-5 border-b border-gray-200">
<p class="text-sm text-gray-500">
<span class="font-medium text-blue-700"><%= post.category.name %></span>
· <%= time_ago_in_words(post.created_at) %> ago
</p>
<% if full %>
<h1 class="mt-2 text-3xl font-bold"><%= post.title %></h1>
<p class="mt-4 leading-relaxed whitespace-pre-line"><%= post.description %></p>
<% else %>
<h2 class="mt-1 text-xl font-semibold">
<%= link_to post.title, post, class: "hover:underline" %>
</h2>
<p class="mt-2 text-gray-700"><%= truncate(post.description, length: 80, separator: " ") %></p>
<% end %>
</article>
ทีละบรรทัด:
- บรรทัด 1
<%# locals: (post:, full: false) -%>— strict locals (บท 123): "แม่พิมพ์นี้รับของ 2 อย่าง ·postต้องส่งมาเสมอ ·fullไม่ส่งก็ได้ ถือว่าfalse" · ขีด-ก่อน%>= ไม่ทิ้งบรรทัดว่างไว้ใน HTML - บรรทัด 2
<article id="<%= dom_id post %>">—dom_idสร้าง id ให้ทุกชิ้นไม่ซ้ำกัน เช่นpost_30(Day 3 ช่วง Hotwire บท 131 จะใช้ id นี้) ·<article>= แท็ก HTML ของ "เนื้อหาหนึ่งชิ้น" - บรรทัด 4
post.category.name— ไปที่หมวดของบทความแล้วเอาชื่อ (association · บท 115) · นี่คือบรรทัดที่ต้องใช้includesไม่งั้นเดินไปตู้ทีละชิ้น - บรรทัด 5
time_ago_in_words(post.created_at)— ได้1 day·about 1 month· เราต่อคำว่าagoเอง · เป็นภาษาอังกฤษเพราะโปรเจกต์นี้ยังไม่ได้ตั้งภาษาไทยให้ helper (เรื่อง i18n ไม่อยู่ในชุดนี้) - บรรทัด 8–10 แบบเต็ม — หัวข้อเป็น
<h1>·whitespace-pre-line= คลาส Tailwind ที่ให้การขึ้นบรรทัดใหม่ในข้อความโชว์เป็นบรรทัดใหม่จริง - บรรทัด 13
link_to post.title, post— ส่ง object ไปตรง ๆ Rails เดา URL ให้เป็น/posts/30(บท 103) · ไม่ต้องเขียน"/posts/#{post.id}"เอง - บรรทัด 15
truncate(…, length: 80, separator: " ")— ตัดเหลือไม่เกิน 80 ตัวอักษร (รวม...) และตัดที่ช่องว่างเท่านั้น · ดูเหตุผลด้านล่าง - คลาสที่เหลือคือ Tailwind (บท 72) อ่านออกเสียงได้:
py-5เว้นบนล่าง ·mt-2เว้นด้านบน ·leading-relaxedระยะห่างระหว่างบรรทัดมากขึ้น ·border-b border-gray-200เส้นเทาอ่อนด้านล่าง ·text-sm text-gray-500ตัวเล็กสีเทา ·text-xl font-semiboldตัวใหญ่หนา ·hover:underlineขีดเส้นใต้ตอนเอาเมาส์ชี้
ทำไมต้องมี separator: " " (ทวนบท 124) — ภาษาไทยมีสระกับวรรณยุกต์ที่ "ลอย" อยู่บนล่างตัวอักษร และนับเป็นตัวอักษรแยก · ถ้าตัดตรงกลางคำ อาจได้คำที่วรรณยุกต์หาย
· ผลจริงกับ "ข้าวเหนียวมะม่วง" (bin/rails runner · ข้อความยาว 98 ตัวอักษร · … หน้าผลคือเราตัดส่วนต้นออกให้เห็นแค่ท้ายที่ถูกตัด):
description[0..80] → …โรยถั่วทองกรอบ ของหวานที่นัก
truncate(description, length: 80) → …โรยถั่วทองกรอบ ของหวานที...
truncate(description, length: 80, separator: " ") → …โรยถั่วทองกรอบ...
แบบที่สอง (truncate เฉย ๆ) ได้ ที... — ไม้เอกของ "ที่" หายไป · truncate ดีกว่า [0..80] ตรงที่เติม ... ให้และรับ separator ได้
แต่ตัวมันไม่ได้รู้จักภาษาไทย · พอบอกให้ตัดที่ช่องว่าง (ภาษาไทยเว้นวรรคระหว่างวลีอยู่แล้ว) ก็ได้วลีครบ
แม่พิมพ์ _post คือแม่พิมพ์ตักข้าวอันเดียวที่ใช้ได้ทั้งจานเล็กในชุดรวม (หน้า index) และจานใหญ่สั่งเดี่ยว (หน้า show)
· คนจัดจานยื่นของใส่มือแม่พิมพ์ชัด ๆ ว่า "นี่ข้าวของจานนี้ (post:) · ขอแบบจานใหญ่ (full: true)"
· แม่พิมพ์ไม่ต้องรู้ว่าตัวเองอยู่ครัวไหน ถูกยกไปใช้ที่ไหนก็ได้
ที่ไม่เหมือนกัน: แม่พิมพ์จริงไม่มีทางเผลอเดินไปหยิบของเอง · แต่ partial เขียน @post ได้ — Rails ไม่ห้าม
มันจะไปควานหาบนถาดหน้าครัวแล้ว "ใช้ได้แค่ครัวเดียว" (ดู predict ข้างล่าง) · strict locals ช่วยเรื่องของที่ต้องยื่น แต่ไม่ได้ห้ามการใช้ @
— จึงต้องตรวจด้วย grep ในขั้นตรวจรับ
เทียบกับ React: partial = component · locals = props (บท 71) ·
full: false = ค่าตั้งต้นของ prop แบบ function Post({ post, full = false }) · render @post, full: true ≈ <Post post={post} full />
· ต่างกันที่ React ไม่มีถาดกลางให้แอบหยิบ — component เห็นแค่ props ที่ส่งมา
รีเฟรช /posts — ชิ้นแรกเป็นแบบนี้ (HTML ที่เบราว์เซอร์ได้จริง จาก curl -s localhost:3000/posts):
<!-- BEGIN app/views/posts/_post.html.erb
--><article id="post_30" class="py-5 border-b border-gray-200">
<p class="text-sm text-gray-500">
<span class="font-medium text-blue-700">เครื่องดื่ม</span>
· 1 day ago
</p>
<h2 class="mt-1 text-xl font-semibold">
<a class="hover:underline" href="/posts/30">นมเย็น</a>
</h2>
<p class="mt-2 text-gray-700">นมสีชมพูจากน้ำแดง หวานหอม เด็ก ๆ ชอบที่สุด เติมไข่มุกได้</p>
</article>
<!-- BEGIN app/views/posts/_post.html.erb -->— ป้ายที่ Rails แปะให้ใน development บอกว่า HTML ก้อนนี้มาจากไฟล์ไหน (production ไม่มี)id="post_30"←dom_id post·เครื่องดื่ม←post.category.name·1 day ago←time_ago_in_words+ คำว่า ago- ไม่มี
<h1>และif/else/endไม่โผล่ใน HTML —<% %>ทำในครัวเงียบ ๆ ไม่ขึ้นจาน (บท 122) - ข้อความนี้สั้นกว่า 80 ตัวอักษรเลยไม่ถูกตัด · ชิ้นที่ 5 (มัสมั่นเนื้อ) จะเห็น
...ท้าย
log ตอนนี้: (4 queries, 0 cached) และคำเตือน AVOID ของ bullet หายไปแล้ว — เพราะเราใช้หมวดที่หยิบมาจริง
สมมติเผลอเขียนบรรทัด 4 ของ partial เป็น @post.category.name (แบบที่ tutorial หลายที่เขียน) แล้วเปิดสองหน้า:
http://localhost:3000/posts ← หน้ารายการ
http://localhost:3000/posts/30 ← หน้าเต็ม
หน้าไหนพัง หน้าไหนรอด? (ตอนนี้ show.html.erb ยังเป็นของ scaffold ที่เรียก render @post)
ผลจริง: หน้ารายการพัง · หน้าเต็มรอด
GET /posts → 500 ActionView::Template::Error (undefined method 'category' for nil)
GET /posts/30 → 200
หน้า show: controller วาง @post (ตัวเดียว) ไว้บนถาด → partial ไปควานเจอพอดี · หน้า index: controller วาง @posts (มี s) ไม่มี @post
→ ได้ nil แล้วถาม nil ว่า "หมวดของคุณคืออะไร" → พัง (ActionView::Template::Error = พังระหว่างจัดจานใน view)
อันตรายตรงที่ถ้าคุณทดสอบแค่หน้า show จะคิดว่าใช้ได้ · นี่คือเหตุผลที่ prompt ห้าม "แตะ @post ข้างใน partial" · ลองแล้วแก้กลับเป็น post.category.name ก่อนไปต่อ
- strict locals · local variable vs instance variable
- strict locals = บรรทัด
<%# locals: (...) %>ที่ประกาศว่า partial รับอะไร · local = ของที่ยื่นใส่มือ (post) · instance variable = ของบนถาดหน้าครัว (@post) · พิมพ์บอก AI ได้ว่า "partials must only use locals declared with strict locals; never reference instance variables inside a partial" truncate·separator· grapheme- truncate ตัดตามจำนวนตัวอักษร · separator บอกให้ตัดที่ตัวคั่น · grapheme = "ตัวที่ตาเห็นเป็นหนึ่งตัว" เช่น "ที่" อาจประกอบจากหลายตัวอักษรในคอมพิวเตอร์ · ถาม senior ได้ว่า "ข้อความไทยที่ตัดสั้น ทีมเราใช้ separator: ' ' หรือมี helper กลางอยู่แล้ว"
ขั้น 4 — หน้า index กับแถบเปลี่ยนหน้า
เขียนทับ app/views/posts/index.html.erb ทั้งไฟล์:
<% content_for :title, "บทความทั้งหมด" %>
<div class="w-full max-w-3xl mx-auto">
<% if notice.present? %>
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-md inline-block" id="notice"><%= notice %></p>
<% end %>
<div class="flex justify-between items-center gap-4">
<h1 class="font-bold text-3xl sm:text-4xl">บทความทั้งหมด</h1>
<%= link_to "เขียนบทความใหม่", new_post_path, class: "shrink-0 rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium" %>
</div>
<div id="posts">
<%= render(@posts) || tag.p("ไม่พบบทความ", class: "text-center text-gray-500 my-10") %>
</div>
<%= render "shared/pagination", pagy: @pagy %>
</div>
- บรรทัด 1
content_for :title— ส่งชื่อหน้าไปติดที่แท็บเบราว์เซอร์ (บท 123) - บรรทัด 3
max-w-3xl mx-auto— กว้างไม่เกินประมาณหน้ากระดาษแล้ววางกลางจอ อ่านง่ายกว่าเต็มจอ - บรรทัด 4–6 กล่องแจ้งเตือนสีเขียว — ของเดิมจาก scaffold ไม่ได้แตะ ·
notice= ข้อความแจ้งครั้งเดียวที่ controller ฝากมากับredirect_to(เช่น "ลบบทความแล้ว" ที่เพิ่งแปลในขั้น 2 · บท 102) ·.present?= "มีค่าและไม่ว่าง" (บท 94) - บรรทัด 8–11 หัวข้อกับปุ่ม ·
flex justify-between items-center= วางเรียงแนวนอน ดันหัวข้อไปซ้าย ปุ่มไปขวา กึ่งกลางแนวตั้ง ·gap-4= ช่องว่างระหว่างกัน ·sm:text-4xl= ใหญ่ขึ้นเมื่อจอกว้าง ·shrink-0= ปุ่มห้ามหดตอนจอแคบ (หัวข้อจะตัดบรรทัดแทน) ·new_post_path= ชื่อ route ไม่ใช่ string"/posts/new" - บรรทัด 14 — หัวใจของหน้า:
render(@posts)= "ใช้แม่พิมพ์_postกับทุกชิ้นใน@posts" (Rails เดาชื่อ partial จากชนิดของของ · ส่งแต่ละชิ้นเป็น local ชื่อpostให้เอง) · ถ้า@postsว่างrenderคืนnil(คู่มือ Rails เขียนไว้) →||("ถ้าทางซ้ายว่าง ใช้ทางขวา") จึงไปทำtag.p("ไม่พบบทความ")แทน ·tag.p(...)= helper สร้างแท็ก<p>(บท 124) - บรรทัด 17
render "shared/pagination", pagy: @pagy— ใช้แม่พิมพ์app/views/shared/_pagination.html.erb(โฟลเดอร์shared= ของที่ทุกแผนกใช้ร่วม) และยื่น@pagyใส่มือในชื่อpagy
ทำไมไม่เขียน if @posts.any? แบบ scaffold: ขั้น 2 เห็นแล้วว่า any? ทำให้มี Post Exists? เพิ่ม 1 ครั้ง → 4 query เกินเกณฑ์ "ไม่เกิน 3" (เกณฑ์ตรวจรับของสไลด์ · เจอเต็ม ๆ ในขั้น 7)
· ส่วน render(...) || ไม่ต้องถามตู้เพิ่ม เพราะรู้ว่าว่างจากของที่หยิบมาแล้ว
ต่อไปสร้างโฟลเดอร์ app/views/shared/ (ยังไม่มี · mkdir app/views/shared หรือให้ editor สร้าง) แล้วสร้าง
app/views/shared/_pagination.html.erb:
<%# locals: (pagy:) -%>
<nav class="mt-8 flex items-center justify-between gap-2 text-sm" aria-label="เปลี่ยนหน้า">
<% if pagy.previous %>
<%= link_to "← ก่อนหน้า", pagy.page_url(:previous), class: "rounded-md px-3 py-2 bg-gray-100 hover:bg-gray-200 font-medium" %>
<% else %>
<span class="rounded-md px-3 py-2 text-gray-400">← ก่อนหน้า</span>
<% end %>
<span class="text-gray-600">หน้า <%= pagy.page %> / <%= pagy.last %></span>
<% if pagy.next %>
<%= link_to "ถัดไป →", pagy.page_url(:next), class: "rounded-md px-3 py-2 bg-gray-100 hover:bg-gray-200 font-medium" %>
<% else %>
<span class="rounded-md px-3 py-2 text-gray-400">ถัดไป →</span>
<% end %>
</nav>
- บรรทัด 1 — แม่พิมพ์นี้รับของอย่างเดียวคือ
pagy(ไม่มีค่าตั้งต้น = ต้องส่งมาเสมอ) - บรรทัด 2
<nav aria-label="เปลี่ยนหน้า">— แท็กของ "แถบนำทาง" ·aria-label= ชื่อที่โปรแกรมอ่านหน้าจอสำหรับคนตาบอดอ่านออกเสียง ·flex justify-between= วางสามชิ้นซ้าย–กลาง–ขวา - บรรทัด 3
pagy.previous— เลขหน้าก่อนหน้า หรือnilถ้าอยู่หน้าแรก ·nilนับเป็น "เท็จ" ในif(บท 94) - บรรทัด 4
pagy.page_url(:previous)— pagy สร้าง URL ของหน้านั้นให้ เช่น/posts?page=1· เราไม่ต้องต่อ string เอง - บรรทัด 6 ถ้าไม่มีหน้าก่อน โชว์ข้อความสีเทาที่กดไม่ได้ (
<span>ไม่ใช่ลิงก์) — ปุ่มไม่หายไป แถบจึงไม่ขยับไปมา - บรรทัด 9
pagy.page= หน้าปัจจุบัน ·pagy.last= หน้าสุดท้าย (= จำนวนหน้าทั้งหมด) - บรรทัด 11–15 ฝั่ง "ถัดไป" แบบเดียวกัน ·
pagy.nextเป็นnilเมื่ออยู่หน้าสุดท้าย
⚠️ ทำไมไม่ใช้แถบสำเร็จรูปของ pagy (series_nav) — มันคืน HTML เป็นข้อความธรรมดา ·
ใส่ใน <%= %> จะถูกใส่ซองใสจนโชว์เป็นตัวหนังสือ <nav class="pagy series-nav"… บนหน้าเว็บ (ผลจริง) ·
คู่มือของ pagy เองจึงเขียน <%== @pagy.series_nav %> — <%== คือตัวย่อของ raw = แกะซองออก
ซึ่งขัดกับข้อห้ามใน prompt · เราจึงเขียนแถบเองด้วย link_to 16 บรรทัด
รีเฟรช /posts · แถบท้ายหน้าที่เบราว์เซอร์ได้จริง (จาก curl -s localhost:3000/posts):
<nav class="mt-8 flex items-center justify-between gap-2 text-sm" aria-label="เปลี่ยนหน้า">
<span class="rounded-md px-3 py-2 text-gray-400">← ก่อนหน้า</span>
<span class="text-gray-600">หน้า 1 / 3</span>
<a class="rounded-md px-3 py-2 bg-gray-100 hover:bg-gray-200 font-medium" href="/posts?page=2">ถัดไป →</a>
</nav>
หน้า 1 → "ก่อนหน้า" เป็น <span> สีเทา (มาจากบรรทัด 6) · "ถัดไป" เป็นลิงก์ /posts?page=2 (บรรทัด 12) · ส่วน log ของหน้านี้ (ผลจริง ครบทั้งก้อน):
Started GET "/posts" for ::1 at 2026-11-07 10:38:12 +0700
Processing by PostsController#index as */*
Post Count (0.9ms) SELECT COUNT(*) FROM "posts" /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
↳ app/controllers/posts_controller.rb:6:in 'PostsController#index'
Rendering layout layouts/application.html.erb
Rendering posts/index.html.erb within layouts/application
Post Load (0.7ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT 10 OFFSET 0 /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
↳ app/views/posts/index.html.erb:15
Category Load (0.7ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (5, 4, 3, 2, 1) /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
↳ app/views/posts/index.html.erb:15
Rendered collection of posts/_post.html.erb [10 times] (Duration: 1.1ms | GC: 0.1ms)
Rendered shared/_pagination.html.erb (Duration: 0.0ms | GC: 0.0ms)
Rendered posts/index.html.erb within layouts/application (Duration: 4.4ms | GC: 0.2ms)
Rendered layout layouts/application.html.erb (Duration: 4.6ms | GC: 0.2ms)
Completed 200 OK in 6ms (Views: 4.0ms | ActiveRecord: 1.3ms (3 queries, 0 cached) | GC: 0.2ms)
- สองบรรทัดแรก:
for ::1= คำขอมาจากเครื่องตัวเอง (::1คือ localhost แบบ IPv6 · เหมือน127.0.0.1) ·as */*เพราะผมยิงด้วย curl — เปิดในเบราว์เซอร์จะเห็นas HTML(ผลจริงทั้งสองแบบ) - 3 queries = นับ (pagy) + หยิบ 10 แผ่น + หยิบหมวดทั้งตะกร้า ·
Post Exists?หายไปแล้ว - สังเกตบรรทัด
↳(บอกว่าบรรทัดไหนของโค้ดทำให้ไปตู้):COUNTเกิดใน controller เพราะ pagy นับทันที · แต่Post Loadเกิดใน view บรรทัด 15 — ใบเบิกยังไม่ถูกยื่นจนถึงตอนrender(@posts)ใช้ข้อมูลจริง (บท 113) (เลขบรรทัดของ view ใน log อาจคลาดจากไฟล์ 1 บรรทัด — ดูชื่อไฟล์เป็นหลักพอ) Rendered collection of posts/_post.html.erb [10 times]= แม่พิมพ์ถูกใช้ 10 ครั้ง
⚠️ ถ้าหน้าดูเหมือนไม่มี style เลย (ไม่มีระยะห่าง ไม่มีสี) แปลว่าคุณเปิดร้านด้วย bin/rails server เฉย ๆ แทน bin/dev
— คลาส Tailwind ใหม่ ๆ ที่เพิ่งพิมพ์ (max-w-3xl · text-3xl …) ยังไม่ถูกสร้างลงไฟล์ CSS · สั่ง bin/rails tailwindcss:build หรือเปลี่ยนไปใช้ bin/dev (เหตุผลอยู่ในบท 128)
ขั้น 5 — หน้า show พร้อมทางกลับ (breadcrumb)
breadcrumb คือแถบเล็ก ๆ บนหัวหน้าที่บอกว่า "ตอนนี้อยู่ตรงไหน และกลับไปทางไหนได้" เช่น บทความทั้งหมด › เครื่องดื่ม › นมเย็น
· ชื่อมาจากนิทานที่โปรยเศษขนมปังไว้หาทางกลับบ้าน · เขียนทับ app/views/posts/show.html.erb ทั้งไฟล์:
<% content_for :title, @post.title %>
<div class="w-full max-w-3xl mx-auto">
<% if notice.present? %>
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-md inline-block" id="notice"><%= notice %></p>
<% end %>
<nav class="text-sm text-gray-500" aria-label="breadcrumb">
<%= link_to "บทความทั้งหมด", posts_path, class: "hover:underline" %>
›
<%= link_to @post.category.name, @post.category, class: "hover:underline" %>
›
<span class="text-gray-800"><%= @post.title %></span>
</nav>
<%= render @post, full: true %>
<div class="mt-6 flex flex-col sm:flex-row gap-2">
<%= link_to "แก้ไข", edit_post_path(@post), class: "text-center rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-200 font-medium" %>
<%= link_to "กลับไปหน้ารวม", posts_path, class: "text-center rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-200 font-medium" %>
<%= button_to "ลบบทความ", @post, method: :delete, class: "w-full rounded-md px-3.5 py-2.5 text-white bg-red-600 hover:bg-red-500 font-medium cursor-pointer", data: { turbo_confirm: "ลบบทความนี้จริงไหม?" } %>
</div>
</div>
- บรรทัด 1 — ชื่อแท็บเบราว์เซอร์เป็นหัวข้อบทความ (แทน "Showing post")
- บรรทัด 8–14 breadcrumb ·
posts_path→/posts·link_to @post.category.name, @post.category→ ส่ง object หมวดไป Rails เดาได้/categories/5· ชิ้นสุดท้ายเป็น<span>ไม่ใช่ลิงก์ (อยู่หน้านี้แล้ว) ·›เป็นตัวอักษรธรรมดา - ที่นี่ใช้
@postได้ —show.html.erbไม่ใช่ partial (ชื่อไม่ขึ้นต้นด้วย_) มันคือคนจัดจานของเมนูนี้ที่หยิบจากถาดได้ตามปกติ - บรรทัด 16
render @post, full: true— แม่พิมพ์ตัวเดิม แต่ยื่นfull: trueไปด้วย → ได้แบบเต็ม - บรรทัด 18–22 ปุ่มสามปุ่ม ·
flex-col sm:flex-row gap-2= จอแคบเรียงลง จอกว้างเรียงข้าง ·edit_post_path(@post)= ชื่อ route ของหน้าแก้ไข ได้/posts/30/edit·button_to … method: :delete= ปุ่มที่ส่งคำขอลบ (บท 124) ·data: { turbo_confirm: "…" }= ขึ้นกล่องถาม "ลบบทความนี้จริงไหม?" ก่อนลบ (เป็นความสามารถของ Turbo ที่มีในโปรเจกต์อยู่แล้ว · Turbo ละเอียดในบท 130)
เปิด /posts/30 · breadcrumb กับตัวบทความที่เบราว์เซอร์ได้จริง (curl -s localhost:3000/posts/30):
<nav class="text-sm text-gray-500" aria-label="breadcrumb">
<a class="hover:underline" href="/posts">บทความทั้งหมด</a>
›
<a class="hover:underline" href="/categories/5">เครื่องดื่ม</a>
›
<span class="text-gray-800">นมเย็น</span>
</nav>
<!-- BEGIN app/views/posts/_post.html.erb
--><article id="post_30" class="py-5 border-b border-gray-200">
<p class="text-sm text-gray-500">
<span class="font-medium text-blue-700">เครื่องดื่ม</span>
· 1 day ago
</p>
<h1 class="mt-2 text-3xl font-bold">นมเย็น</h1>
<p class="mt-4 leading-relaxed whitespace-pre-line">นมสีชมพูจากน้ำแดง หวานหอม เด็ก ๆ ชอบที่สุด เติมไข่มุกได้</p>
</article>
ครึ่งล่างมาจากแม่พิมพ์ _post ตัวเดียวกับหน้ารายการ แต่เข้าฝั่ง if full → ได้ <h1> กับข้อความเต็ม ·
log ของหน้านี้ (ผลจริง): (2 queries, 0 cached) = หยิบบทความ 1 แผ่น + หยิบหมวด 1 แผ่น · ลิงก์ เครื่องดื่ม พาไปหน้าหมวดของ scaffold (ยังเป็นภาษาอังกฤษ ไม่ได้แตะในบทนี้)
เดินเข้าศูนย์อาหารชั้น 5 จะมีป้าย "ศูนย์อาหาร › โซนเครื่องดื่ม › ร้านนมเย็น" ให้รู้ว่าอยู่ตรงไหน และอยากกลับไปดูทั้งโซนก็เดินตามป้ายได้ทันที · breadcrumb คือป้ายนั้นบนหน้าเว็บ — คนที่เข้ามาจาก Google ตรงไปหน้าบทความเลย ก็ยังรู้ว่ามีหมวดเครื่องดื่มทั้งหมวดให้ดูต่อ
ที่ไม่เหมือนกัน: ป้ายในห้างติดตายอยู่กับที่ · breadcrumb ถูกสร้างใหม่ทุกครั้งจากข้อมูลของบทความ — ย้ายบทความไปหมวดอื่น ป้ายก็เปลี่ยนตามเอง · และมันไม่ได้จำว่าคุณเดินมาทางไหน: ต่อให้มาจากหน้า 3 ของรายการ ปุ่ม "บทความทั้งหมด" ก็พากลับหน้า 1
- breadcrumb ·
aria-label - breadcrumb = แถบบอกตำแหน่ง "หน้าแรก › หมวด › หน้านี้" ·
aria-label= ชื่อของส่วนนั้นสำหรับโปรแกรมอ่านหน้าจอ · พิมพ์บอก AI ได้ว่า "add a breadcrumb to posts#show: All posts › category name (linked) › post title, using link_to with route helpers" - render with locals ·
full: true - ส่งของเพิ่มให้ partial ตอนเรียก
render @post, full: true· ใน React คือ prop แบบ boolean · ถาม senior ได้ว่า "partial ตัวเดียวสองโหมดแบบนี้ ทีมเราโอเคไหม หรือแยกเป็นสองไฟล์ดีกว่า"
ขั้น 6 — ฟอร์ม: เลือกหมวดจากรายการ แทนพิมพ์เลข
ฟอร์มของ scaffold ให้ พิมพ์เลข category_id เอง — ผู้ใช้จะรู้ได้ยังไงว่าหมวดของหวานคือเลข 3 · เปลี่ยนเป็นกล่องเลือก (dropdown)
ด้วย collection_select (บท 124) · ไฟล์ app/views/posts/_form.html.erb ทั้งไฟล์หลังแก้:
<%= form_with(model: post, class: "contents") do |form| %>
<% if post.errors.any? %>
<div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-md mt-3">
<h2>บันทึกไม่ได้ มี <%= post.errors.count %> จุดที่ต้องแก้:</h2>
<ul class="list-disc ml-6">
<% post.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="my-5">
<%= form.label :title, "หัวข้อ" %>
<%= form.text_field :title, class: ["block shadow-sm rounded-md border px-3 py-2 mt-2 w-full", {"border-gray-400 focus:outline-blue-600": post.errors[:title].none?, "border-red-400 focus:outline-red-600": post.errors[:title].any?}] %>
</div>
<div class="my-5">
<%= form.label :description, "เนื้อหา" %>
<%= form.textarea :description, rows: 4, class: ["block shadow-sm rounded-md border px-3 py-2 mt-2 w-full", {"border-gray-400 focus:outline-blue-600": post.errors[:description].none?, "border-red-400 focus:outline-red-600": post.errors[:description].any?}] %>
</div>
<div class="my-5">
<%= form.label :category_id, "หมวด" %>
<%= form.collection_select :category_id, Category.all, :id, :name, { prompt: "เลือกหมวด" }, class: ["block shadow-sm rounded-md border px-3 py-2 mt-2 w-full", {"border-gray-400 focus:outline-blue-600": post.errors[:category].none?, "border-red-400 focus:outline-red-600": post.errors[:category].any?}] %>
</div>
<div class="inline">
<%= form.submit "บันทึก", class: "w-full sm:w-auto rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
</div>
<% end %>
เทียบกับของเดิม จุดที่เปลี่ยน (ที่เหลือเป็นของ scaffold ทั้งหมด):
- บรรทัด 4 หัวกล่อง error — เดิม
pluralize(post.errors.count, "error") … prohibited this post from being saved:เปลี่ยนเป็นภาษาไทย - บรรทัด 15 · 20 · 25
form.label :title, "หัวข้อ"— argument ที่สองคือข้อความที่โชว์ (ไม่ใส่ = Rails ใช้ชื่อช่องภาษาอังกฤษ) - บรรทัด 26
collection_selectแทนtext_field— อ่านทีละ argument::category_idช่องที่จะส่ง ·Category.allรายการให้เลือก ·:idค่าที่ส่งจริง ·:nameข้อความที่ผู้ใช้เห็น ·{ prompt: "เลือกหมวด" }ตัวเลือกแรกที่ว่าง · ก้อนท้ายclass: […]คือ class ของแท็ก
ทำไมมีปีกกาสองก้อน: ก้อนแรกคือ "ตั้งค่าของ helper" ก้อนหลังคือ "attribute ของแท็ก HTML" · ถ้าไม่มี{ }ครอบก้อนแรก Ruby จะรวมทุกอย่างเป็นก้อนเดียวแล้ว class หาย - บรรทัด 26 (ท้าย)
post.errors[:category]แทนpost.errors[:category_id]— เมื่อไม่เลือกหมวดbelongs_toใส่ error ไว้ที่ชื่อ:category("Category must exist" · บท 115) ไม่ใช่:category_id· ถ้าใช้ของเดิม ขอบกล่องจะไม่เป็นสีแดงเลย - บรรทัด 30
form.submit "บันทึก"— ข้อความบนปุ่ม (เดิม Rails ตั้งเป็น "Create Post" / "Update Post")
ก้อน class: [ "…", { "…": เงื่อนไข } ] ยาว ๆ ที่ไม่ได้แตะ อ่านยังไง — เป็นของ scaffold ·
ช่องแรกคือคลาสที่ใช้เสมอ · ในปีกกาคือ "คลาสนี้ : ใส่เมื่อเงื่อนไขเป็นจริง" · ช่องไม่มี error (.none?) ได้ขอบเทา · มี error (.any?) ได้ขอบแดง
· ดูผลได้ใน HTML ข้างล่าง: ฟอร์มเปล่ายังไม่มี error จึงได้ border-gray-400 focus:outline-blue-600 ต่อท้าย
เปิด /posts/new · กล่องเลือกที่เบราว์เซอร์ได้จริง (curl -s localhost:3000/posts/new):
<label for="post_category_id">หมวด</label>
<select class="block shadow-sm rounded-md border px-3 py-2 mt-2 w-full border-gray-400 focus:outline-blue-600" name="post[category_id]" id="post_category_id"><option value="">เลือกหมวด</option>
<option value="1">อาหารไทย</option>
<option value="2">ก๋วยเตี๋ยว</option>
<option value="3">ของหวาน</option>
<option value="4">อาหารริมทาง</option>
<option value="5">เครื่องดื่ม</option></select>
name="post[category_id]"— ชื่อช่องตรงกับที่ controller รอในparams.expect(post: [ :title, :description, :category_id ])เป๊ะ (บท 117) — controller ไม่ต้องแก้value="1"(ส่งจริง) ←:id·อาหารไทย(ผู้ใช้เห็น) ←:name·<option value="">เลือกหมวด←prompt:
ลองกด บันทึก โดยไม่กรอกอะไรเลย (ตรวจด้วย Chrome จริง): ได้กล่องแดง
บันทึกไม่ได้ มี 3 จุดที่ต้องแก้: Category must exist · Title can't be blank · Description can't be blank และกล่องเลือกหมวดขอบแดง
· ข้อความ error ยังเป็นภาษาอังกฤษเพราะมาจาก Rails (ไม่ได้ตั้งภาษาไทย) · ใน Console ของเบราว์เซอร์ (คลิกขวา → Inspect → แท็บ Console) จะมี 422 (Unprocessable Content) — ปกติของฟอร์มที่ไม่ผ่าน validation ไม่ใช่บั๊ก
สุดท้ายของขั้นนี้ แปลข้อความในหน้า new กับ edit · app/views/posts/new.html.erb ทั้งไฟล์:
<% content_for :title, "เขียนบทความใหม่" %>
<div class="md:w-2/3 w-full">
<h1 class="font-bold text-4xl">เขียนบทความใหม่</h1>
<%= render "form", post: @post %>
<%= link_to "กลับไปหน้ารวม", posts_path, class: "w-full sm:w-auto text-center mt-2 sm:mt-0 sm:ml-2 rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium" %>
</div>
app/views/posts/edit.html.erb ทั้งไฟล์:
<% content_for :title, "แก้ไขบทความ" %>
<div class="md:w-2/3 w-full">
<h1 class="font-bold text-4xl">แก้ไขบทความ</h1>
<%= render "form", post: @post %>
<%= link_to "ดูบทความนี้", @post, class: "w-full sm:w-auto text-center mt-2 sm:mt-0 sm:ml-2 rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium" %>
<%= link_to "กลับไปหน้ารวม", posts_path, class: "w-full sm:w-auto text-center mt-2 sm:mt-0 sm:ml-2 rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium" %>
</div>
เปลี่ยนแค่ข้อความ · class เดิมทุกตัว · render "form", post: @post = ยื่น @post ใส่มือแม่พิมพ์ _form ในชื่อ post
(นี่คือเหตุผลที่ใน _form เขียน post.errors ไม่ใช่ @post.errors)
ขั้น 7 — เวลาไทย และไฟล์ prompt ของ Lab
ใน config/application.rb (ไฟล์ตั้งค่าของทั้งแอป) มีบรรทัดที่ถูกปิดไว้ด้วย # · แก้บรรทัดนี้:
# config.time_zone = "Central Time (US & Canada)"
เป็น:
config.time_zone = "Asia/Bangkok"
ปิดแล้วเปิดร้านใหม่ (ไฟล์ใน config/ ถูกอ่านแค่ตอนเปิดร้าน) · อยากเห็นผลต่างเอง ให้ถามเวลาสร้างของ "นมเย็น" ด้วย bin/rails runner (รันโค้ด Ruby หนึ่งบรรทัดในแอป) ทั้งก่อนและหลังแก้ · ผลจริง:
$ bin/rails runner 'p Time.zone.name; p Post.find_by!(title: "นมเย็น").created_at'
# ก่อนแก้
"UTC"
2026-11-06 04:14:55.753375000 UTC +00:00
# หลังแก้
"Asia/Bangkok"
2026-11-06 11:14:55.753375000 +07 +07:00
เวลาเดียวกัน แต่แสดงเป็นเวลาไทย (+7 ชั่วโมง) · ในตู้ยังเก็บเป็น UTC เหมือนเดิม · จะเห็นผลเวลาเปิด /posts.json หรือเมื่อโชว์วันที่ (บท 124 l)
· time_ago_in_words ไม่เปลี่ยน เพราะมันนับแค่ส่วนต่าง
ต่อไปคือ prompt ของ Lab · สร้างไฟล์ prompts/01-views.md ตามสไลด์หน้า 21 (ทั้งไฟล์ ข้างใน prompts/ ที่มี 00-context.md อยู่แล้ว · บท 121):
## เป้าหมาย
หน้า index กับ show ของ Post ที่คนอ่านรู้เรื่อง
ใช้ partial ร่วมกัน และมีข้อมูลตัวอย่างพอให้เห็นปัญหา
## ข้อกำหนด
- partial app/views/posts/_post.html.erb
รับค่าผ่าน local ห้ามใช้ instance variable
- index ใช้ render @posts ไม่ต้องเขียน each เอง
- โชว์ชื่อหมวด ไม่ใช่ category_id
- ตัดคำอธิบายด้วย truncate ไม่ใช่ substring
- แสดงเวลาด้วย time_ago_in_words
- index ต้องมี pagination หน้าละ 10
- controller ต้อง includes(:category)
- show มี breadcrumb กลับไปหน้า index
- db/seeds.rb สร้าง 5 หมวด 30 โพสต์ ข้อความไทย
## ห้าม
- ใช้ raw หรือ html_safe กับข้อมูลจากผู้ใช้
- Post.all ที่ไม่มี limit
- เขียน path เป็น string เช่น "/posts/#{id}"
- แตะ @post ข้างใน partial
## เกณฑ์ตรวจรับ
- [ ] เปิด /posts แล้วเห็นรายการพร้อมชื่อหมวด
- [ ] log โชว์ไม่เกิน 3 query ต่อการโหลด 1 หน้า
- [ ] partial ไม่มีคำว่า @ อยู่เลย
- [ ] grep หาคำว่า raw กับ html_safe แล้วไม่เจอ
- [ ] มี pagination ใช้งานได้จริง
- [ ] seed รันซ้ำได้ไม่พัง
สไลด์หน้า 21 ชี้ว่า ทุกบรรทัดในข้อกำหนดกับข้อห้ามมาจากสิ่งที่เพิ่งเรียน — ถ้าเมื่อเช้าให้เขียน prompt นี้ เราจะเขียนได้แค่บรรทัดเป้าหมาย · ลองจับคู่ดูว่าแต่ละบรรทัดมาจากไหน และใน Lab นี้เราทำตรงไหน:
| บรรทัดใน prompt | เรียนที่ | ใน Lab นี้ |
|---|---|---|
| partial รับค่าผ่าน local · แตะ @post ข้างใน partial (ห้าม) | บท 123 | ขั้น 3 (strict locals · predict) |
| index ใช้ render @posts | บท 123 | ขั้น 4 |
| โชว์ชื่อหมวด · controller ต้อง includes | บท 114 · บท 125 | ขั้น 2–3 |
| truncate ไม่ใช่ substring · time_ago_in_words | บท 124 | ขั้น 3 |
| pagination หน้าละ 10 · Post.all ที่ไม่มี limit (ห้าม) | กฎข้อ 6 ใน 00-context (บท 121) | ขั้น 1–2 · 4 |
| breadcrumb | บทนี้ | ขั้น 5 |
| path เป็น string (ห้าม) | บท 101 (ชื่อ route _path) | ทุกขั้นใช้ posts_path · link_to …, post |
| raw / html_safe (ห้าม) | บท 125 | ขั้น 4 (ไม่ใช้ series_nav) |
| seeds 5 หมวด 30 โพสต์ | บท 120 | มีอยู่แล้ว · ตรวจในขั้นตรวจรับ |
แก้ prompts/00-context.md อีกหนึ่งบรรทัด (ในหัวข้อ "รูปแบบโค้ด") · บรรทัดเดิม:
- ข้อความที่ผู้ใช้เห็นเป็นภาษาไทย ผ่าน i18n
เปลี่ยนเป็น:
- ข้อความที่ผู้ใช้เห็นเป็นภาษาไทย เขียนตรง ๆ ใน view (ยังไม่ใช้ i18n)
ทำไม: i18n คือระบบแยกข้อความไปเก็บในไฟล์ภาษาแล้วเรียกด้วย t(".title") · โปรเจกต์ของเรายังไม่ได้ตั้งระบบนั้น (เขียนภาษาไทยตรง ๆ ใน view อย่างที่ทำมาทั้ง Lab)
· ถ้าปล่อยบรรทัดเดิมไว้ AI ที่อ่าน context จะเขียน t(".title") มาให้ แล้วหน้าเว็บโชว์ข้อความแปลก ๆ แทนภาษาไทย — context ที่ไม่ตรงกับโค้ดจริงทำให้ AI เข้าใจผิด
00-context.md คือแฟ้มคู่มือพนักงานใหม่ที่แนบไปทุกกะ (กฎถาวรของร้าน) · 01-views.md คือใบสั่งงานของกะนี้
ที่เขียนว่าต้องทำอะไร · ห้ามอะไร · และ "ตอนส่งงาน หัวหน้าจะตรวจ 6 ข้อนี้" · พนักงานใหม่ (AI) ที่ได้ทั้งสองแผ่นจะทำงานได้ใกล้เคียงคนเก่า
· ถ้าคู่มือเขียนผิด (บอกให้ใช้ระบบที่ร้านไม่มี) พนักงานก็ทำผิดตามคู่มืออย่างตั้งใจ
ที่ไม่เหมือนกัน: พนักงานคนจริงทำผิดครั้งหนึ่งแล้วจำได้ · AI ไม่จำข้ามครั้ง — สิ่งเดียวที่ทำให้มันไม่พลาดซ้ำคือเราเขียนลงแฟ้ม (ภารกิจท้ายบท) · และพนักงานคนจริงบอกได้ว่า "ไม่แน่ใจ" แต่ AI มักส่งงานที่ดูครบเสมอ — ใบตรวจรับจึงสำคัญกว่าใบสั่งงาน
- acceptance criteria · checklist ตรวจรับ
- รายการที่บอกว่า "งานแบบไหนถึงเรียกว่าเสร็จ" ตรวจได้ด้วยคำสั่งหรือการกระทำที่ชัด ไม่ใช่ความรู้สึก · พิมพ์บอก AI ได้ว่า "before you say you're done, run each acceptance check in prompts/01-views.md and paste the output"
- time zone · i18n
- time zone = เขตเวลาที่แอปใช้แสดงผล · i18n (internationalization — i กับ n มีตัวอักษรคั่น 18 ตัว) = ระบบแยกข้อความหลายภาษา · ถาม senior ได้ว่า "โปรเจกต์นี้จะทำ i18n ไหม ถ้าทำ เริ่มตั้งแต่ตอนนี้จะถูกกว่าย้ายทีหลังไหม"
ตรวจรับตาม checklist ของสไลด์หน้า 21 — ทีละข้อ
ถึงตรงนี้โค้ดครบแล้ว · ตรวจรับเหมือนเป็นงานที่คนอื่น (หรือ AI) ส่งมา — ทุกข้อมีคำสั่งตรวจจริงและผลจริง:
| เกณฑ์ | ตรวจด้วย | ผลจริง |
|---|---|---|
| เห็นรายการพร้อมชื่อหมวด | เปิด /posts | 10 ชิ้น · ชิ้นแรก เครื่องดื่ม · 1 day ago / นมเย็น |
| ไม่เกิน 3 query ต่อหน้า | tail -f log/development.log (ตามสไลด์หน้า 22 · ให้ log ไหลขึ้นจอเรื่อย ๆ · กด Ctrl+C เพื่อหยุด) แล้วดู Completed … ของทุกหน้า | /posts · ?page=2 · ?page=3 = 3 · /posts/30 = 2 · ?page=99 = 2 |
partial ไม่มี @ | grep -n "@" app/views/posts/_*.html.erb app/views/shared/_*.html.erb | ไม่มี output (ไม่เจอ) |
| ไม่มี raw / html_safe | grep -rn "raw\|html_safe\|<%==" app/views | ไม่มี output |
| pagination ใช้ได้จริง | กด "ถัดไป →" · ลองพิมพ์ ?page= แปลก ๆ | ดูตารางและ predict ข้างล่าง |
| seed รันซ้ำไม่พัง | bin/rails db:seed สองรอบ | สองรอบได้ categories: 5 · posts: 30 เท่ากัน |
grep = คำสั่งค้นหาข้อความในไฟล์ · -n บอกเลขบรรทัด · -r ค้นทั้งโฟลเดอร์ · \| = "หรือ" ·
_*.html.erb = ทุกไฟล์ที่ขึ้นต้นด้วย _ (คือ partial ทั้งหมด) · ไม่มี output = ไม่เจอ = ผ่าน
⚠️ คำสั่ง grep ของสไลด์หน้า 22 ("raw\|html_safe") จับ <%== ไม่ได้ — ผมลองเติมบรรทัด <%== @pagy.series_nav %> ต่อท้ายไฟล์ index
แล้วรัน grep ของสไลด์: ไม่มี output (ผ่านทั้งที่มีของแกะซองอยู่) · เติม \|<%== ต่อท้ายแบบในตาราง แล้วเจอทันที:
app/views/posts/index.html.erb:19:<%== @pagy.series_nav %> (ลองแล้วลบบรรทัดนั้นออก)
pagination: ลองค่าแปลก ๆ
นี่คือเหตุผลที่ไม่เขียนเอง — ผลจริงจาก curl ทีละ URL (ถ้าคุณเคยทำ Go บท 35 ที่ต้องเขียน binding:"min=1" เองแล้วได้ 400 · pagy เลือกอีกทาง):
| URL | บทความ | แถบเปลี่ยนหน้า | query |
|---|---|---|---|
/posts | 10 | หน้า 1 / 3 · ถัดไป → ?page=2 | 3 |
/posts?page=2 | 10 | หน้า 2 / 3 · ก่อนหน้า ?page=1 · ถัดไป ?page=3 | 3 |
/posts?page=3 | 10 | หน้า 3 / 3 · ถัดไปเป็นสีเทา | 3 |
/posts?page=0 · ?page=abc | 10 | หน้า 1 / 3 (pagy ถือว่าเป็นหน้า 1 ไม่ error) | 3 |
ใน Chrome จริง: กด "ถัดไป →" แล้ว URL เปลี่ยนเป็น /posts?page=2 หัวข้อแรกเป็น ชามะนาว ·
และจอกว้าง 375px (ขนาดมือถือ) ทุกหน้าไม่มีแถบเลื่อนแนวนอน
http://localhost:3000/posts?page=99
มีแค่ 3 หน้า แต่ขอหน้า 99 — ได้อะไร? หน้าแดง error · ถูกพากลับหน้า 3 · หรืออย่างอื่น? และแถบเปลี่ยนหน้าเขียนว่าอะไร
ผลจริง: ได้ 200 หน้าว่าง (ไม่ error · ไม่พากลับ) · HTML ส่วนกลาง (curl -s "localhost:3000/posts?page=99"):
<div id="posts">
<p class="text-center text-gray-500 my-10">ไม่พบบทความ</p>
</div>
<!-- BEGIN app/views/shared/_pagination.html.erb
--><nav class="mt-8 flex items-center justify-between gap-2 text-sm" aria-label="เปลี่ยนหน้า">
<a class="rounded-md px-3 py-2 bg-gray-100 hover:bg-gray-200 font-medium" href="/posts?page=3">← ก่อนหน้า</a>
<span class="text-gray-600">หน้า 99 / 3</span>
<span class="rounded-md px-3 py-2 text-gray-400">ถัดไป →</span>
</nav>
ไม่พบบทความ←render(@posts)คืนnilเพราะไม่มีชิ้นไหน →||ไปทางขวา (ขั้น 4 บรรทัด 14 ทำงานจริง)หน้า 99 / 3ดูแปลกแต่ซื่อตรง · ปุ่ม "← ก่อนหน้า" ชี้ไปหน้า 3 (หน้าสุดท้ายที่มีของ) = ทางกลับ- log:
(2 queries, 0 cached)— ไม่มีบทความ จึงไม่ต้องไปหยิบหมวด - รุ่น 43 ให้หน้าว่างเป็นค่าตั้งต้น · ถ้าอยากให้เป็น error ต้องสั่งเพิ่มเอง (
raise_range_error: trueในโค้ดของ pagy) — Lab นี้ไม่ใช้
ใส่บั๊กกลับ เพื่อให้เชื่อตัวตรวจได้
"log ได้ 3 query" ผ่านแล้ว — แต่ถ้าตัวตรวจไม่เคยจับบั๊กได้เลย เราจะรู้ได้ไงว่ามันทำงาน · ลองลบ .includes(:category) ออกชั่วคราว
ให้เหลือ pagy(Post.recent, limit: 10) แล้วรีเฟรช /posts · log จริง (ตัดบรรทัด ↳ กับ Rendering ออก · ย่อคอมเมนต์ท้าย SQL · ตัดบรรทัด user: กับ Call stack ของ bullet ออก):
Post Count (0.6ms) SELECT COUNT(*) FROM "posts" /*…*/
Post Load (0.7ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT 10 OFFSET 0 /*…*/
Category Load (0.6ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = 5 LIMIT 1 /*…*/
Category Load (0.7ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = 4 LIMIT 1 /*…*/
Category Load (0.7ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = 3 LIMIT 1 /*…*/
Category Load (0.7ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = 2 LIMIT 1 /*…*/
Category Load (0.7ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = 1 LIMIT 1 /*…*/
CACHE Category Load (0.3ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = 5 LIMIT 1
CACHE Category Load (0.3ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = 4 LIMIT 1
CACHE Category Load (0.3ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = 3 LIMIT 1
CACHE Category Load (0.3ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = 2 LIMIT 1
CACHE Category Load (0.3ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = 1 LIMIT 1
Completed 200 OK in 21ms (Views: 14.0ms | ActiveRecord: 4.5ms (12 queries, 5 cached) | GC: 0.0ms)
USE eager loading detected
Post => [:category]
Add to your query: .includes([:category])
- 12 queries — เดินไปตู้ทีละชิ้นต่อบทความ 10 ชิ้น (+ นับ + หยิบบทความ) · ตัวตรวจ "≤ 3" จับได้ทันที
CACHE(cache = กระดาษจดคำตอบไว้ใช้ซ้ำ) — หมวดเลข 5 ถูกถามซ้ำ Rails จำคำตอบเดิมใน request นี้ไว้ ไม่ต้องไปตู้จริง (มีแค่ 5 หมวด แต่ 10 บทความ) · แต่ยังเป็นบั๊ก ถ้ามี 10 หมวดก็จะไปตู้จริงครบ 10 ครั้ง- bullet เตือน USE eager loading ("ควรใช้ includes") และหน้าเว็บมีกล่อง bullet footer โผล่ท้ายหน้า (เป็นของ development เท่านั้น)
ใส่ .includes(:category) กลับ แล้วรีเฟรช → กลับมา 3 queries · ก่อน commit ต้องอยู่ในสถานะนี้
seed รันซ้ำไม่พัง
$ bin/rails db:seed
categories: 5 · posts: 30
$ bin/rails db:seed
categories: 5 · posts: 30
รอบสองตัวเลขไม่เพิ่ม เพราะ seeds ของบท 120 ใช้ find_or_create_by! ("หาแฟ้มนี้ก่อน มีแล้วใช้ของเดิม ไม่มีค่อยสร้าง") ·
นี่คือประโยค ② ของวันในรูปเล็ก ๆ: ทำซ้ำได้โดยไม่เบิ้ล · seed ที่ใช้ create! ตรง ๆ รอบสองจะได้ 60 บทความ
ที่ AI พลาดประจำในบล็อกนี้ — อาการ และตรวจยังไง
สไลด์หน้า 22 รวบรวมไว้ 5 ข้อ · ผมเติม 2 ข้อท้ายที่เจอเองตอนทำ Lab นี้ (pagy เปลี่ยน API บ่อย AI มักเขียนตามรุ่นเก่า):
| AI ทำแบบนี้ | อาการที่เห็น | ตรวจยังไง (≤ 30 วินาที) |
|---|---|---|
ลืม includes(:category) | หน้าเปิดได้ปกติ แค่ช้าลงเมื่อข้อมูลเยอะ | log: (12 queries, 5 cached) · bullet USE eager loading |
ใช้ description[0..80] แทน truncate | ตัดกลางคำ ไม่มี ... | grep -rn "\[0\.\." app/views (\ หน้า [ และ . = ให้หาตัวอักษรนั้นตรง ๆ) · และดูว่า truncate มี separator: ไหม (ไม่มี = วรรณยุกต์หายได้) |
ทำ pagination เองด้วย offset | มักลืมกรณี ?page=0 · ?page=abc · หน้าเกิน → อาจได้หน้าแดงหรือผลแปลก | grep -rn "offset" app/controllers · ลอง URL แปลก ๆ ในตารางด้านบน |
ใส่ @post ใน partial | หน้า show ใช้ได้ หน้า index พัง undefined method … for nil | grep -n "@" app/views/posts/_*.html.erb |
seed ใช้ create! แทน find_or_create_by! | รันรอบสอง ข้อมูลเบิ้ล (60 ชิ้น) | รัน bin/rails db:seed สองรอบ ดูตัวเลข |
| ใช้ API ของ pagy รุ่นเก่า | Pagy::Backend → หน้าแดง NameError · items: 10 → เงียบ ๆ ได้หน้าละ 20 | นับบทความบนหน้า · grep -rn "Pagy::Backend\|items:" app/controllers (ค้นทั้ง app ไม่ได้ — ไปเจอ align-items: ในไฟล์ CSS ที่ build แล้ว) |
ใช้ <%== @pagy.series_nav %> ตามคู่มือ pagy | ใช้ได้สวยงาม แต่เป็นการแกะซอง (= raw) | grep -rn "raw\|html_safe\|<%==" app/views |
สังเกตว่าเกือบทุกข้อ "ใช้ได้" ตอนเปิดดูด้วยตา — เปิดหน้าแล้วสวยไม่ได้แปลว่าผ่าน · checklist ที่รันเป็นคำสั่งได้จึงสำคัญ
ทางเลือก: ลองให้ AI ทำ Lab เดียวกัน แล้วตรวจด้วย checklist ชุดนี้
ทำหลัง commit ของ Lab แล้ว (ส่วนถัดไป) · ไม่บังคับ · ขั้นตอน:
git switch -c ai-vibe1 HEAD~1— สร้าง branch ใหม่จาก commit ก่อน Lab (= สถานะตอนจบบท 120) แล้วย้ายไปอยู่ที่นั่น · โค้ดใน editor จะกลับเป็น scaffold ดิบ (HEAD~1= "ถอยไป 1 commit" · ถ้า commit ภารกิจท้ายบทไปแล้วด้วย ใช้HEAD~2)git checkout @{-1} -- prompts/— ก็อปโฟลเดอร์prompts/จาก branch ที่เพิ่งจากมา (@{-1}= "branch ก่อนหน้า") · AI จะได้มีใบสั่งงานกับคู่มือ- เปิด Claude Code (บท 86) แล้วสั่งประมาณ: "อ่าน @prompts/00-context.md และ @prompts/01-views.md แล้วทำตาม · เสร็จแล้วรันเกณฑ์ตรวจรับทุกข้อให้ดู"
- ตรวจเองด้วยตาราง checklist และตาราง "AI พลาดประจำ" ด้านบน — อย่าเชื่อที่ AI บอกว่าผ่าน รันคำสั่งเอง
- กลับบ้าน:
git add . && git commit -m "ลอง AI"แล้วgit switch -(กลับ branch เดิม) · ปิดเปิดbin/devใหม่ · ถ้า AI แก้ seeds หรือสร้าง migration ข้อมูลในตู้จะเปลี่ยนตาม →bin/rails db:reset(ล้างตู้แล้วสร้างใหม่จาก schema + seeds ของ branch ที่อยู่)
เทียบผลกับของที่คุณทำเอง · ข้อที่ AI พลาด = วัตถุดิบของภารกิจท้ายบท
เก็บเข้าโปรเจกต์
ดูก่อนว่าจะเก็บอะไรบ้าง (ผลจริง):
$ git status --short
M Gemfile
M Gemfile.lock
M app/controllers/application_controller.rb
M app/controllers/posts_controller.rb
M app/views/posts/_form.html.erb
M app/views/posts/_post.html.erb
M app/views/posts/edit.html.erb
M app/views/posts/index.html.erb
M app/views/posts/new.html.erb
M app/views/posts/show.html.erb
M config/application.rb
M prompts/00-context.md
?? app/views/shared/
?? prompts/01-views.md
M = แก้ไฟล์เดิม · ?? = ไฟล์/โฟลเดอร์ใหม่ที่ git ยังไม่รู้จัก · ต้องมีครบ 14 บรรทัดนี้ ไม่มากไม่น้อย
(ถ้ามีไฟล์อื่นโผล่ เช่น ไฟล์ทดลองจากบทก่อน ให้จัดการก่อน) · แล้วเก็บด้วยข้อความ commit ตามสไลด์หน้า 22 (ผลจริง):
$ git add .
$ git commit -m "views: index + show + seed"
[my-day3 265cf20] views: index + show + seed
14 files changed, 121 insertions(+), 60 deletions(-)
create mode 100644 app/views/shared/_pagination.html.erb
create mode 100644 prompts/01-views.md
my-day3 คือ branch ที่สร้างไว้ในบท 120 · เลข commit (265cf20) ของคุณจะต่าง · ตัวเลข 14 files changed, 121 insertions(+), 60 deletions(-) ควรตรงกัน
ถ้าพิมพ์ตามบทเรียนครบทุกตัวอักษร (ต่างนิดหน่อยได้ถ้า Gemfile.lock ได้ pagy รุ่นอื่น)
✅ จบ Lab: โค้ดของคุณตอนนี้คือจุดเช็คพอยต์ A ที่บท 127–129 ใช้ต่อ · บล็อกหน้า (Lab Vibe 2 บท 129) จะเอาหน้าพวกนี้ไปแต่งให้ดูเป็นเว็บด้วย daisyUI
ตามไม่ทัน: มี branch สำเร็จรูปของอาจารย์ day3-a-done แต่ไฟล์ข้างในต่างจากบทเรียนนี้มาก (ดูตารางท้ายบท) · บทต่อ ๆ ไปอ้างโค้ดตามบทนี้ จึงแนะนำให้พิมพ์ตามบทนี้
💬 ติดตรงไหน ถามได้เลย — ถ้าหน้าพังด้วยข้อความที่ไม่มีในบทนี้ หรือ query ไม่ใช่ 3 ส่งสามอย่างไปถาม senior หรือ AI:
log ของ request นั้นทั้งก้อน (จาก Started GET ถึง Completed) · ไฟล์ที่เพิ่งแก้ · ผลของ git diff
ภารกิจ: เติมกฎที่เจอเองลง 00-context.md
สไลด์หน้า 22: ทุกครั้งที่ AI พลาดเรื่องเดิมซ้ำ ให้เติมเป็นกฎถาวรลง 00-context.md · ไฟล์นี้จะโตขึ้นเรื่อย ๆ ตลอดวัน และนั่นคือสิ่งที่ควรเกิด — มันคือบันทึกว่าทีมเราเจออะไรมาแล้ว
🎯 ภารกิจ Lab Vibe 1 (~10 นาที)
- เลือกหนึ่งเรื่องจากตาราง "AI พลาดประจำ" ที่ยังไม่มีใน
prompts/00-context.md(เปิดไฟล์อ่านก่อน — กฎ 1–8 มีอะไรแล้วบ้าง) - เปิด
prompts/00-context.md· เพิ่มหัวข้อใหม่ ท้ายไฟล์ (ใต้ "รูปแบบการตอบ") ชื่อ## กฎที่ทีมเราเจอเองแล้วเขียนกฎ 1 ข้อ · กฎที่ดี ตรวจได้ด้วยคำสั่ง และบอกว่าให้ทำอะไรแทน ไม่ใช่แค่ "ระวัง…" - บันทึกไฟล์ แล้ว
git diff prompts/00-context.md - ถ้าพอใจ
git add prompts/00-context.md && git commit -m "prompts: เพิ่มกฎจาก Lab Vibe 1"
ควรเห็น: ใต้บรรทัด @@ … @@ ของ git diff มีบรรทัดเดิมสามบรรทัด (ขึ้นต้นด้วยช่องว่าง) ตามด้วยบรรทัดใหม่ที่ขึ้นต้นด้วย + ทั้งหมด · ไม่มีบรรทัดที่ขึ้นต้นด้วย - ใต้ @@ เลย
ถ้าเจออาการนี้ แปลว่า…
git diffไม่มี output → ยังไม่ได้กดบันทึกใน editor หรือแก้ผิดไฟล์ (เช่น01-views.md)- มีบรรทัด
-ใต้@@ที่คุณไม่ได้ตั้งใจลบ → editor ไปแก้บรรทัดเดิมด้วย (เช่น ลบช่องว่างท้ายบรรทัด) ·git restore prompts/00-context.mdแล้วทำใหม่ - เขียนกฎเป็นเลข
9.ต่อจากกฎ 8 → ใช้ได้ แต่บท 135 จะเพิ่มกฎ 9–12 ของ Hotwire ไว้ตรงนั้น เลขจะชนกัน · หัวข้อแยกท้ายไฟล์ไม่ชนกับบทไหน
ตัวอย่างหนึ่ง (เรื่อง pagy รุ่นเก่า) · ผล git diff จริง (เลข index … บรรทัดที่สองของคุณจะต่างตามข้อความที่เขียน):
diff --git a/prompts/00-context.md b/prompts/00-context.md
index 53783b1..c0426ef 100644
--- a/prompts/00-context.md
+++ b/prompts/00-context.md
@@ -33,3 +33,8 @@
- บอกชื่อไฟล์ทุกไฟล์ที่แก้หรือสร้าง
- ถ้าต้องมี migration ให้เขียนมาด้วย
- อย่าเพิ่ง refactor อย่างอื่นที่ไม่ได้ขอ
+
+## กฎที่ทีมเราเจอเอง
+
+- pagy รุ่น 43: include Pagy::Method และ pagy(..., limit: 10)
+ ห้ามใช้ Pagy::Backend หรือ items: ของรุ่นเก่า
ตัวอย่างกฎอื่นที่ดี (ตรวจได้ + บอกทางแทน):
- truncate ข้อความไทยต้องมี separator: " " (ตัดที่ช่องว่าง)— ตรวจด้วย grep หาtruncate(แล้วดูว่ามี separator- ห้ามใช้ <%== (คือ raw) · grep ตรวจด้วย "raw\|html_safe\|<%=="- index ที่แสดงข้อมูลว่างใช้ render(@x) || ... ไม่ใช้ @x.any? (เปลือง query)
กฎที่ไม่ดี: "ระวังเรื่อง performance" · "เขียนโค้ดให้ปลอดภัย" — AI อ่านแล้วไม่รู้ว่าต้องทำอะไรต่าง และเราตรวจไม่ได้ว่ามันทำตามไหม · เขียนกฎแล้วก็ยังต้องตรวจ — กฎใน context เป็น "คำแนะนำ" ไม่ใช่การบังคับ (บท 121) · ใช้วิธีในทางเลือกด้านบนดูว่า AI ยังพลาดข้อนั้นไหม
Quiz
ข้อ 1 · หน้า /posts ของ Lab นี้ไปตู้ 3 ครั้ง · ครั้งที่เป็น SELECT COUNT(*) FROM "posts" มีไว้ทำอะไร?
ตัวเลือกที่ 2 ถูก · บรรทัด ↳ ใต้ COUNT ชี้ไปที่ posts_controller.rb:6 (บรรทัดที่เรียก pagy) และผลของมันคือ หน้า 1 / 3 บนแถบ
ตัวเลือกที่ 1 ผิด — ตัวเช็คว่างของ scaffold คือ Post Exists? (SELECT 1 AS one … LIMIT 1) ที่เราเอาออกไปแล้ว ·
ตัวเลือกที่ 3 ผิด — bullet ไม่ยิง query เอง มันแค่ดูว่าแอปยิงอะไร ·
ตัวเลือกที่ 4 ผิด — includes ใช้ WHERE id IN (5, 4, 3, 2, 1) จากบทความที่หยิบมาแล้ว ไม่ต้องนับก่อน
จุดที่ต้องจำ: อ่าน log ให้ดูบรรทัด ↳ ด้วย — มันบอกว่าโค้ดบรรทัดไหนทำให้ไปตู้
ข้อ 2 · AI ส่งงานมา หน้าตาสวย มีแถบเลขหน้าครบ · คุณรัน grep -rn "raw\|html_safe" app/views แล้วไม่มี output · ใน index มี <%== @pagy.series_nav %> — ควรตัดสินยังไง?
ตัวเลือกที่ 4 ถูก · ผลจริงในบท: grep ของสไลด์ไม่เจอ แต่ grep ที่เติม \|<%== เจอทันที · และ <%= @pagy.series_nav %> (เท่ากับหนึ่งตัว) โชว์ HTML เป็นตัวหนังสือ — แปลว่า <%== คือการแกะซองจริง
ตัวเลือกที่ 1 ผิดตรงที่ "grep ไม่เจอ = ผ่าน" — ตัวตรวจมีรูรั่ว · และกฎ "ห้ามแกะซอง" ช่วยให้ทั้งทีมไม่ต้องมานั่งแยกว่าตัวไหนปลอดภัย ·
ตัวเลือกที่ 2 ผิด — <%== ไม่เหมือน <%= (ผลจริงต่างกันชัด) ·
ตัวเลือกที่ 3 ผิดเหตุผล — การใช้ gem ไม่ผิด (เราก็ใช้ pagy.page_url) ที่ผิดคือการแกะซอง
จุดที่ต้องจำ: ก่อนเชื่อตัวตรวจ ลองใส่บั๊กให้มันจับก่อน · ถ้าจับไม่ได้ แก้ตัวตรวจ
ข้อ 3 · partial _post ใช้ทั้งหน้า index (render(@posts)) และ show (render @post, full: true) · ถ้าใน partial เขียน @post.title แทน post.title จะเกิดอะไร?
ตัวเลือกที่ 1 ถูก · ผลจริงเมื่อลองเปลี่ยนเป็น @post.title ใน partial ของ Lab ที่เสร็จแล้ว: GET /posts → 500 ActionView::Template::Error (undefined method 'title' for nil) · GET /posts/30 → 200 (แบบเดียวกับ predict ในขั้น 3)
ตัวเลือกที่ 2 ผิด — Rails ไม่แปลงให้ · @post ใน partial คือการไปควานถาดหน้าครัว ซึ่งหน้า index มีแต่ @posts ·
ตัวเลือกที่ 3 ผิด — strict locals คุมแค่ "ของที่ยื่นมา" ไม่ได้ห้ามการใช้ @ (ถ้าห้าม หน้า show ก็ต้องพังด้วย แต่ผลจริงได้ 200) ·
ตัวเลือกที่ 4 กลับด้าน — full: true เป็น local แยก ไม่เกี่ยวกับ @post
จุดที่ต้องจำ: partial ที่แตะ @ "ใช้ได้แค่ครัวเดียว" — ตรวจด้วย grep "@" ในไฟล์ที่ขึ้นต้นด้วย _ ไม่ใช่ด้วยการเปิดดูหน้าเดียว
ข้อ 4 · AI เขียน db/seeds.rb ด้วย Post.create!(title: …) 30 บรรทัด · รัน bin/rails db:seed ครั้งแรกได้ 30 บทความ · รันครั้งที่สองจะได้อะไร และควรแก้ยังไง?
ตัวเลือกที่ 3 ถูก · create! = "สร้างแผ่นใหม่เสมอ" · seeds ของบท 120 ใช้ find_or_create_by! จึงรันสองรอบแล้วได้ posts: 30 เท่าเดิม (ผลจริงในขั้นตรวจรับ)
ตัวเลือกที่ 1 ผิด — create! ไม่ถามตู้ก่อน · ตัวเลือกที่ 2 ผิด — ตาราง posts ไม่มี unique index ที่ title (บทความชื่อซ้ำได้ตามธุรกิจ) จึงไม่ error
และการเพิ่ม index เพื่อแก้ seed คือแก้ผิดที่ · ตัวเลือกที่ 4 ผิด — "อย่ารันซ้ำ" ไม่ใช่การแก้ เพื่อนร่วมทีม (หรือ AI) จะรันซ้ำแน่นอน
จุดที่ต้องจำ: ออกแบบให้ทำซ้ำได้ ดีกว่าหวังว่าจะไม่มีใครทำซ้ำ — ประโยค ② ของ Day 3
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง | ต้องสนใจไหม |
|---|---|---|---|
| 22 | description[0..80] "พังกับภาษาไทย" (นัยว่า truncate ไม่พัง) | truncate(…, length: 80) ก็ตัดกลางคำ วรรณยุกต์หายได้ (ผลจริง "ของหวานที...") ต้องเติม separator: " " | สนใจ — ขั้น 3 |
| 22 | ตรวจด้วย grep -rn "raw\|html_safe" app/views | จับ <%== (ตัวย่อของ raw) ไม่ได้ · ผลจริงไม่มี output ทั้งที่มี | สนใจ — เติม \|<%== |
| 22 | ตามไม่ทัน git checkout day3-a-done | branch ของอาจารย์ใช้ i18n · rails-i18n · daisyUI · seeds คนละชุด ไฟล์ต่างจากบทเรียน | แค่รู้ไว้ |
| 21 | ข้อกำหนด "db/seeds.rb สร้าง 5 หมวด 30 โพสต์" | ทำไว้แล้วตั้งแต่บท 120 · Lab นี้แค่ตรวจว่ารันซ้ำได้ | แค่รู้ไว้ |
| 18 · 21 | "21 query → 2" · เกณฑ์ "ไม่เกิน 3 query" | ของเราหน้าละ 10 และ pagy เพิ่ม COUNT: ไม่มี includes = 12 (5 cached) · มี = 3 พอดี | แค่รู้ไว้ |
อ้างอิง
- pagy — Quick Start —
อ่านหัวข้อ Steps ส่วนที่เขียน
include Pagy::Methodและ@pagy, @records = pagy(...)(ตรงกับขั้น 2) · สังเกตว่าคู่มือใช้<%== @pagy.series_nav %>ซึ่งเราเลี่ยง - pagy — page_url —
รายการค่าที่ส่งได้ (
:previous·:next·:first·:last) ที่ใช้ในแถบเปลี่ยนหน้าของเรา - Rails Guides — Layouts and Rendering —
อ่านหัวข้อ Rendering Collections ช่วงท้าย ที่บอกว่า "In the event that the collection is empty, render will return nil" (ที่มาของ
render(@posts) || …) - Rails Guides — Action View Overview —
อ่านหัวข้อ Strict Locals วิธีเขียน
<%# locals: (…) %>และค่าตั้งต้นแบบfull: false - Rails API — TextHelper#truncate —
ตัวอย่างการใช้
separator:· และ FormOptionsHelper#collection_select ลำดับ argument (ขั้น 6) - Rails Guides — Active Record Query Interface — อ่านหัวข้อ includes ตัวอย่างทางการของการหยิบทั้งตะกร้าแบบเดียวกับขั้น 2