🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้
- view ·
<%= %>(บท 103 · บท 122) — คนจัดจาน ·<%= %>= คำนวณแล้ววางผลบนจาน ·<% %>= ทำเงียบ ๆ ไม่ขึ้นจาน - helper (บท 103) — method สำเร็จรูปที่เรียกใน view แล้วได้ข้อความหรือ HTML กลับมา · ตัวแรกที่เจอคือ
link_to - partial
_form.html.erb(บท 118 · บท 123) — แม่พิมพ์ฟอร์มชิ้นเดียวที่หน้า new กับ edit เรียกใช้ด้วยrender "form", post: @post persisted?· validation ·errors(บท 107 · 110) — "อยู่ในตู้แล้วหรือยัง" · กฎตรวจก่อนบันทึก · โน้ตสีเหลืองบอกว่าผิดข้อไหนparams.expect(post: [ … ])(บท 117) — พนักงานต้อนรับที่รับเฉพาะช่องที่อยู่บนแบบฟอร์ม
🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 (สไลด์และ repo ของอาจารย์ใช้ Ruby 4.0.5–4.0.6 · Rails 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน)
📍 บทนี้เริ่มจากโค้ดสถานะไหน: แอปกลางของ Day 3 ตามที่ตั้งไว้ในบท 120 (branch my-day3 ที่แตกจาก day3-start + seeds 30 บทความที่ commit ไว้แล้ว) — ตามไม่ทันให้ทำบท 120 ก่อน ·
บทนี้เป็นบททดลอง แก้ไฟล์ได้ตามสบาย จบบทแล้วสั่ง git restore กลับ (ท้ายภารกิจบอกคำสั่ง)
ผลรันทุกกล่องมาจากเครื่องจริง · คำว่า about 1 month · วันที่ · เวลา · เลข 0x… · ค่า authenticity_token · ตัวเลข ms ใน log ของคุณจะไม่ตรงกับของผม
(วันที่และเวลาขึ้นกับวันที่คุณรัน seeds · ที่เหลือสุ่มหรือเปลี่ยนใหม่ทุกครั้งที่รัน)
1 · helper คืออะไร — เครื่องมือในลิ้นชักของคนจัดจาน
helper คือ method ที่ Rails เตรียมไว้ให้เรียกใน view เพื่อทำงานจัดหน้าตาที่ทุกหน้าต้องทำซ้ำ ๆ —
ตัดข้อความยาวให้สั้น · บอกว่า "เขียนเมื่อ 1 เดือนก่อน" · ใส่หน่วยเงินให้ตัวเลข ·
มันรับค่าเข้าไป แล้วคืนข้อความ (หรือ HTML) ออกมาให้ <%= %> วางบนหน้า
ถ้าไม่มี helper ทุกหน้าต้องเขียนวิธีตัดข้อความ วิธีจัดวันที่ของตัวเอง · ผลคือแต่ละหน้าทำไม่เหมือนกัน และพังคนละแบบ
คนจัดจาน (view) มีลิ้นชักเครื่องมือ: ที่ตัดเค้กให้ชิ้นเท่ากัน · ขวดบีบซอสที่บีบเป็นเส้นสวย · ตรายางวันที่ ·
เขาไม่ต้องกะเอาด้วยมีดเองทุกจาน หยิบเครื่องมือมาใช้ ทุกจานก็ออกมาหน้าตาเดียวกัน ·
link_to ที่ใช้มาตั้งแต่บท 103 คือเครื่องมือชิ้นแรกในลิ้นชักนี้
ที่ไม่เหมือนกัน: คนจัดจานจริงปรับมือได้ตามหน้างาน · helper ทำตามสูตรเป๊ะ — ที่ตัดเค้กไม่รู้ว่าข้อความเป็นภาษาไทย (เดี๋ยวเห็นในหัวข้อ 2) · และเครื่องมือหลายชิ้นพูดภาษาอังกฤษเป็นค่าตั้งต้น จนกว่าเราจะใส่ภาษาไทยให้มัน
เทียบกับ Next.js: ในฝั่ง FRONT เราเขียนฟังก์ชันเล็ก ๆ เช่น formatDate(post.createdAt) แล้วเรียกใน JSX ด้วย {formatDate(…)} ·
helper คือฟังก์ชันแบบนั้นที่ Rails เขียนมาให้แล้วเป็นร้อยตัว และเราเพิ่มของเราเองได้ (หัวข้อ 4) ·
เทียบกับ Go: คล้าย package strings · fmt ที่มีฟังก์ชันจัดข้อความสำเร็จรูป แต่ helper ถูกออกแบบมาเพื่อใช้ในหน้าเว็บโดยเฉพาะ
ลองใน console ยังไง: console (ครัวทดลอง บท 104) ไม่ใช่ view จึงเรียก helper ตรง ๆ ไม่ได้ ·
ต้องเรียกผ่านตัวแปรชื่อ helper ที่ Rails เตรียมไว้ให้ใน console · ผมลองทั้งสองแบบ:
wongnok-blog-all-in-one(dev):001> truncate("ต้มยำกุ้ง", length: 5)
(wongnok-blog-all-in-one):1:in '<main>': undefined method 'truncate' for main (NoMethodError)
wongnok-blog-all-in-one(dev):002> helper.truncate("ต้มยำกุ้ง", length: 5)
=> "ต้..."
- บรรทัด 001 = เรียกเฉย ๆ →
NoMethodError"ไม่รู้จัก methodtruncate" · คำว่าfor main= "ในตัว console เอง" · console ไม่ได้ถือลิ้นชักเครื่องมือของ view - บรรทัด 002 = เรียกผ่าน
helper.→ ได้ผล · ใน view ไม่ต้องมีhelper.เขียน<%= truncate(…) %>ได้เลย "ต้..."= ยาวรวม 5 ตัว:ต+้(ไม้โท นับเป็นหนึ่งตัว) + จุดสามตัว · เรื่องนี้แหละที่หัวข้อถัดไปจะขุด
- view helper
- method สำเร็จรูปที่เรียกใน view เพื่อจัดหน้าตา (ตัดข้อความ · จัดวันที่ · สร้างลิงก์) · ก่อนเขียนเองทุกครั้ง ถาม AI ได้ว่า "is there a built-in Rails view helper for shortening text?"
helper(ใน console)- ตัวแปรใน
bin/rails consoleที่ให้ลองเรียก helper ได้ เช่นhelper.truncate(…)· ใช้ทดลองก่อนเอาไปวางใน view · พิมพ์บอก AI ได้ว่า "show me how to try this helper in rails console with helper.xxx first"
2 · ตัดข้อความภาษาไทย — [0..19] กับ truncate
หน้ารายการบทความอยากโชว์แค่ต้นเรื่องสั้น ๆ · วิธีที่คนเขียน Ruby นึกออกก่อน (และ AI ชอบเขียน) คือหยิบตัวอักษรช่วงแรกด้วย [0..19]
= "ตัวที่ 0 ถึงตัวที่ 19" (รวม 20 ตัว) · ลองกับบทความแรกใน seeds (ต้มยำกุ้งน้ำข้น) ซึ่งเนื้อหาขึ้นต้นว่า
"ต้มยำกุ้งแบบน้ำข้นใส่นมข้นจืด รสเปรี้ยวเผ็ดกลมกล่อม …":
wongnok-blog-all-in-one(dev):001> post = Post.find(1)
wongnok-blog-all-in-one(dev):002> post.description[0..19]
ตัวอักษรตัวที่ 20 ของประโยคนี้ตกอยู่กลางคำว่า "ใส่" · บรรทัด 002 จะได้อะไร — ตัวอักษรเพี้ยนเป็นเครื่องหมายแปลก ๆ? ได้ "ใส่" ครบ? หรืออย่างอื่น?
ผลจริง (ตัดบรรทัด SQL ออก):
wongnok-blog-all-in-one(dev):001> post = Post.find(1)
=>
#<Post:0x0000000125d4ffc8
...
wongnok-blog-all-in-one(dev):002> post.description[0..19]
=> "ต้มยำกุ้งแบบน้ำข้นใส"
- ตัวอักษรไม่เพี้ยน — Ruby นับเป็น "ตัวอักษร" ไม่ได้นับเป็นไบต์ (ไบต์ = หน่วยย่อยที่คอมพิวเตอร์ใช้เก็บข้อมูล · ตัวไทยหนึ่งตัวกินหลายไบต์ ถ้าตัดกลางไบต์ตัวอักษรจะแตก) จึงไม่มีตัวไหนแตกเป็นเครื่องหมาย
? - แต่ความหมายเปลี่ยน — "ใส่" กลายเป็น "ใส" · ในภาษาไทย ไม้เอก สระบน สระล่างนับเป็นตัวอักษรแยกที่ลอยอยู่บนพยัญชนะ · ตัดตรงไหนก็ได้ ไม่สนว่ากลางคำหรือเปล่า ไม้เอกที่อยู่ตัวที่ 21 จึงหลุดไป · ต้มยำ "น้ำข้นใส" จึงโผล่บนหน้าเว็บ
- ไม่มี
...ต่อท้าย ผู้อ่านไม่รู้ด้วยซ้ำว่าข้อความถูกตัด
ทีนี้ลองเครื่องมือของลิ้นชัก — truncate แปลว่า "ตัดให้สั้น":
wongnok-blog-all-in-one(dev):003> helper.truncate(post.description, length: 40)
=> "ต้มยำกุ้งแบบน้ำข้นใส่นมข้นจืด รสเปรี้..."
wongnok-blog-all-in-one(dev):004> helper.truncate(post.description, length: 40, separator: " ")
=> "ต้มยำกุ้งแบบน้ำข้นใส่นมข้นจืด..."
length: 40= "ผลลัพธ์ยาวไม่เกิน 40 ตัว รวมจุดสามตัวแล้ว" · truncate เติม...ให้เอง ผู้อ่านรู้ว่ายังมีต่อ- บรรทัด 003 ยังตัดกลางคำ ("รสเปรี้...") — truncate ก็นับตัวอักษรเหมือน
[0..19]ไม่ได้รู้จักคำไทยเลย separator: " "(บรรทัด 004) = "ถ้าจะตัด ให้ถอยไปตัดที่ช่องว่างตัวสุดท้าย" · คนไทยเว้นวรรคระหว่างวลี จุดตัดจึงอ่านรู้เรื่อง: "…ใส่นมข้นจืด..."- ถ้าในช่วงนั้นไม่มีช่องว่างเลย separator ช่วยไม่ได้ — ผมลอง
length: 20และ30กับประโยคเดียวกัน ได้ผลเหมือนไม่ใส่ separator (ตัดกลางคำ) เพราะช่องว่างแรกอยู่ตัวที่ 30
✅ สูตรที่ใช้กับภาษาไทย: truncate(ข้อความ, length: 80, separator: " ") · ได้สองอย่างที่ [0..N] ไม่มี — จุดสามตัว และจุดตัดที่ช่องว่าง ·
แต่อย่าเข้าใจว่า truncate "รู้จักคำไทย" — มันไม่ตัดกลางคำก็ต่อเมื่อใส่ separator และข้อความช่วงนั้นมีช่องว่าง
3 · ลิ้นชักมีอะไรบ้าง — 4 กลุ่มที่ใช้บ่อย
ผมเปิด console แล้วลองทีละตัวกับข้อมูลจริง (post = บทความเลข 1 · ตัดบรรทัด SQL และบางบรรทัดออก เลขบรรทัดจึงกระโดด):
wongnok-blog-all-in-one(dev):002> helper.pluralize(1, "comment")
=> "1 comment"
wongnok-blog-all-in-one(dev):003> helper.pluralize(3, "comment")
=> "3 comments"
wongnok-blog-all-in-one(dev):007> helper.time_ago_in_words(post.created_at)
=> "about 1 month"
wongnok-blog-all-in-one(dev):008> helper.l(post.created_at, format: :long)
=> "October 08, 2026 03:35"
wongnok-blog-all-in-one(dev):009> helper.number_to_human(12500)
=> "12.5 Thousand"
wongnok-blog-all-in-one(dev):010> helper.number_to_currency(99)
=> "$99.00"
wongnok-blog-all-in-one(dev):011> helper.number_to_currency(99, unit: "฿")
=> "฿99.00"
wongnok-blog-all-in-one(dev):012> helper.tag.div("อร่อย", class: "card")
=> "<div class=\"card\">อร่อย</div>"
รวมเป็นตาราง — คอลัมน์ขวาคือผลจริงจาก console (เครื่องหมาย \" ใน console คือ " ธรรมดา ผมเขียนตารางเป็นแบบที่หน้าเว็บได้รับจริง):
| กลุ่ม | helper | ทำอะไร | ผลจริง |
|---|---|---|---|
| ลิงก์ / ฟอร์ม | link_to("แก้", "/posts/1/edit") | ลิงก์ (บท 103) | <a href="/posts/1/edit">แก้</a> |
button_to "ลบ", post, method: :delete | ฟอร์มเล็ก ๆ ที่มีปุ่มเดียว · ใช้กับงานที่เปลี่ยนข้อมูล เช่น ลบ | ดูปุ่ม Destroy ในหน้า show ของ scaffold | |
form_with model: post | ฟอร์มทั้งใบ | หัวข้อ 5–7 ของบทนี้ | |
| ข้อความ | truncate(text, length: 40, separator: " ") | ตัดให้สั้น + ... | ต้มยำกุ้งแบบน้ำข้นใส่นมข้นจืด... |
pluralize(3, "comment") | ตัวเลข + คำนามอังกฤษที่เติม s ให้ถูก | 3 comments · ถ้า 1 ได้ 1 comment | |
simple_format("บรรทัดแรก\nบรรทัดสอง") | เปลี่ยนการขึ้นบรรทัดใหม่ในข้อความเป็น HTML | <p>บรรทัดแรก<br />บรรทัดสอง</p> | |
highlight("ต้มยำกุ้งน้ำข้น", "กุ้ง") | ไฮไลต์คำที่ค้นหา | ต้มยำ<mark>กุ้ง</mark>น้ำข้น | |
| ตัวเลข / เวลา | time_ago_in_words(post.created_at) | ห่างจากตอนนี้นานแค่ไหน | about 1 month |
l(post.created_at, format: :long) | จัดวันที่ตามภาษาของแอป (l = localize) | October 08, 2026 03:35 | |
number_to_human(12500) | ย่อเลขใหญ่ให้คนอ่าน | 12.5 Thousand | |
number_to_currency(99, unit: "฿") | ใส่หน่วยเงิน + ทศนิยม 2 ตำแหน่ง | ฿99.00 · ไม่ใส่ unit ได้ $99.00 | |
| อื่น ๆ | tag.div("อร่อย", class: "card") | สร้างแท็ก HTML จากโค้ด Ruby | <div class="card">อร่อย</div> |
image_tag("/icon.png", alt: "โลโก้") | แท็กรูป | <img alt="โลโก้" src="/icon.png" /> |
สามเรื่องที่ชอบทำให้งง:
time_ago_in_wordsไม่มีคำว่า ago — ได้แค่about 1 month· ต้องพิมพ์ago(หรือ "ที่แล้ว") ต่อท้ายเองnumber_to_currencyใส่$เป็นค่าตั้งต้น — ร้านไทยต้องบอกunit: "฿"ทุกครั้ง- ทุกอย่างเป็นภาษาอังกฤษ (about 1 month · October · Thousand) และเวลา
03:35คือเวลาสากล UTC (บท 107 · ช้ากว่าไทย 7 ชั่วโมง เพราะแอปยังไม่ได้ตั้งเขตเวลา — Lab บท 126 ตั้งให้)
ทำไมเป็นอังกฤษ: helper พวกนี้ไม่ได้เขียนคำตายตัวไว้ในโค้ด มันไปเปิดสมุดคำแปลของแอป (ไฟล์ใน config/locales/) ตามภาษาที่แอปตั้งไว้ ·
แอปของเรามีแค่สมุดภาษาอังกฤษ (en.yml) จึงได้อังกฤษ
ประเด็นที่ต้องจำ: ใช้ l(…) แล้ว วันหนึ่งเปลี่ยนภาษาได้ที่เดียวทั้งแอป ·
ถ้าเขียนรูปแบบวันที่ตายตัวเอง (strftime("%d/%m/%Y") = "เอาวัน/เดือน/ปี มาเรียงแบบนี้เป๊ะ ๆ") ต้องไล่แก้ทุกไฟล์ ·
(การใส่ภาษาไทยให้ทั้งแอปไม่ได้ทำในบทเรียนนี้ — ดูตารางท้ายบท)
l() คือตรายางวันที่ในลิ้นชัก · ตอนนี้ร้านมีแผ่นยางภาษาอังกฤษแผ่นเดียว ปั๊มกี่จานก็ได้ "October 08" ·
วันไหนร้านได้แผ่นยางภาษาไทยมา (สมุดคำแปล th) เปลี่ยนแผ่นเดียว ทุกจานเป็นภาษาไทยทันที ·
ส่วน strftime คือการเขียนวันที่ด้วยมือลงบนจานทีละใบ — อยากเปลี่ยนต้องเขียนใหม่ทุกใบ
ที่ไม่เหมือนกัน: แผ่นยางจริงซื้อมาใส่แล้วใช้ได้เลย · สมุดคำแปลของ Rails ต้องตั้งค่าให้แอปเลือกภาษาด้วย (default_locale) ·
และมันเปลี่ยนแค่คำ ไม่ได้เปลี่ยนเขตเวลา — เวลา UTC ก็ยังเป็น UTC
- i18n · locale
- i18n = การทำให้แอปพูดได้หลายภาษา (ย่อจาก internationalization: i + 18 ตัว + n) · locale = ภาษาที่กำลังใช้ เช่น
:en:th· ถาม senior ได้ว่า "time_ago_in_words ออกมาเป็นอังกฤษ ต้องเพิ่ม locale th หรือติด rails-i18n?" (rails-i18n = gem สมุดคำแปลสำเร็จรูปหลายภาษา) l(localize)- helper จัดวันที่/เวลาตามภาษาของแอป ใช้แทนการเขียนรูปแบบวันที่ตายตัว · พิมพ์บอก AI ได้ว่า "format dates with l(…, format: :long), not strftime"
4 · เขียน helper ของร้านเอง — reading_time
ของที่ลิ้นชักไม่มี เราเขียนเพิ่มเองได้ · ไฟล์อยู่ใน app/helpers/ (scaffold สร้าง posts_helper.rb ว่าง ๆ ไว้ให้แล้ว) ·
method ที่เขียนในไฟล์นี้ Rails ส่งให้ view เรียกใช้ได้เอง ไม่ต้อง import อะไร ·
เทียบ Next.js: เหมือนไฟล์ lib/format.ts ที่รวมฟังก์ชันจัดข้อความไว้ แต่ไม่ต้องเขียน import ที่หัวไฟล์ component
เปิด app/helpers/posts_helper.rb แล้วเติมให้เป็นแบบนี้ (สูตรจากสไลด์หน้า 15 — "บทความนี้อ่านกี่นาที"):
# app/helpers/posts_helper.rb
module PostsHelper
def reading_time(text)
"#{(text.to_s.split.size / 200.0).ceil} นาที"
end
end
module PostsHelper … end= กล่องรวม method (scaffold สร้างไว้แล้ว) · module คือกล่องที่ Rails เอาไปเสียบให้ view ใช้ได้ · เราเติมแค่def … endข้างในtext.to_s= เปลี่ยนเป็นข้อความก่อน · ถ้าtextเป็นnilจะได้""แทนที่จะพัง.split= หั่นข้อความตรงช่องว่าง ได้ array ของ "คำ" ·.size= นับว่ามีกี่ชิ้น/ 200.0= สไลด์สมมติว่าคนอ่านได้นาทีละ 200 คำ · ต้องมี.0ไม่งั้น Ruby หารเลขเต็มทิ้งเศษ (11 / 200 ได้ 0).ceil= ปัดขึ้น (0.055 → 1) ·"#{…} นาที"= เอาตัวเลขไปแทรกในข้อความ (string interpolation บท 91)
ลองใน console (ถ้าเปิดค้างไว้ พิมพ์ reload! ก่อน · ไม่งั้นมันยังไม่เห็น method ใหม่ · บรรทัด 001 คือ post = Post.find(1) เหมือนเดิม):
wongnok-blog-all-in-one(dev):002> post.description.split.size
=> 11
wongnok-blog-all-in-one(dev):003> helper.reading_time(post.description)
=> "1 นาที"
wongnok-blog-all-in-one(dev):004> english = "very tasty " * 1000
wongnok-blog-all-in-one(dev):005> english.split.size
=> 2000
wongnok-blog-all-in-one(dev):006> helper.reading_time(english)
=> "10 นาที"
wongnok-blog-all-in-one(dev):007> thai = "อร่อยมาก" * 1000
wongnok-blog-all-in-one(dev):008> thai.length
=> 8000
wongnok-blog-all-in-one(dev):009> thai.split.size
=> 1
wongnok-blog-all-in-one(dev):010> helper.reading_time(thai)
=> "1 นาที"
wongnok-blog-all-in-one(dev):011> helper.reading_time(nil)
=> "0 นาที"
"very tasty " * 1000 = เอาข้อความมาต่อกัน 1,000 รอบ (ผมตัดบรรทัด => ที่ console โชว์ข้อความยาว ๆ ออก) ·
ภาษาอังกฤษ 2,000 คำ ได้ 10 นาที ตรงตามสูตร · แต่ภาษาไทย 8,000 ตัวอักษร ได้แค่ "1 นาที"
เพราะภาษาไทยไม่เว้นวรรคระหว่างคำ · split เห็นทั้งก้อนเป็น "คำเดียว" ·
บทความใน seeds ทั้ง 30 ชิ้นนับได้แค่ 3–11 "คำ" จึงได้ "1 นาที" ทุกชิ้น · สูตรนี้ใช้ฝึกเขียน helper ได้ แต่อย่าเชื่อตัวเลขกับภาษาไทย ·
ถ้าจะทำจริงต้องนับแบบอื่น (เช่นนับตัวอักษร) และต้องหาตัวเลขความเร็วอ่านภาษาไทยจากแหล่งที่เชื่อได้ — ผมไม่เดาตัวเลขให้
ลิ้นชักไม่มีเครื่องมือที่ร้านต้องการ ก็สั่งทำเอง แล้วเก็บไว้ในลิ้นชักเดียวกัน · คนจัดจานทุกคนหยิบใช้ได้เหมือนเครื่องมือมาตรฐาน ·
ไฟล์ app/helpers/posts_helper.rb คือช่องในลิ้นชักที่ติดป้ายว่า "ของแผนกบทความ"
ที่ไม่เหมือนกัน: ป้าย "ของแผนกบทความ" เป็นแค่การจัดหมวด ไม่ใช่กุญแจ ·
Rails เปิดให้ทุกหน้าใช้ helper จากทุกไฟล์ — ผมเช็คแล้ว หน้าของหมวด (categories) ก็เรียก reading_time ได้ (วิธีเช็คอยู่ในเฉลย quiz ข้อ 3) ·
และเครื่องมือที่เราสั่งทำไม่มีใครตรวจคุณภาพให้ — เครื่องมือ reading_time ของเราวัดภาษาไทยผิด ไม่มี error สักตัวเตือน
- custom helper
- helper ที่เราเขียนเองใน
app/helpers/*.rb· ใช้เมื่อ view หลายหน้าต้องคำนวณ/จัดข้อความแบบเดียวกัน · พิมพ์บอก AI ได้ว่า "move this formatting logic out of the view into a helper in app/helpers/posts_helper.rb" - module
- กล่องรวม method ของ Ruby ที่เอาไปเสียบให้ class อื่นใช้ได้ · helper ทุกไฟล์เป็น module · ถาม senior ได้ว่า "helper ตัวนี้ควรอยู่ใน module ไหน ApplicationHelper หรือ PostsHelper"
5 · form_with — ฟอร์มชิ้นเดียว ใช้ทั้งหน้า new และ edit
บท 118 เราอ่านฟอร์มของ scaffold ผ่าน ๆ ไปแล้ว · บทนี้ดูให้ลึกขึ้นว่า form_with สร้าง HTML อะไรให้ และทำไมไม่ควรเขียนช่องเอง ·
นี่คือไฟล์ฟอร์มของ Post (ผมตัด class: … ที่ยาวมากออกให้เห็นโครง ไฟล์จริงมี class ยาวทุกบรรทัด):
<%# app/views/posts/_form.html.erb (ตัด class ออก) %>
<%= form_with(model: post) do |form| %>
<% if post.errors.any? %>
… กล่องแดง: <%= pluralize(post.errors.count, "error") %> prohibited … + รายการ error …
<% end %>
<%= form.label :title %>
<%= form.text_field :title %>
<%= form.label :description %>
<%= form.textarea :description, rows: 4 %>
<%= form.label :category_id %>
<%= form.text_field :category_id %>
<%= form.submit %>
<% end %>
form_with(model: post) do |form|= "วาดฟอร์มของแฟ้มpostนี้" ·formคือผู้ช่วยวาดช่อง (สไลด์เขียนสั้นว่าf— ชื่อตัวแปรเฉย ๆ ตั้งอะไรก็ได้)form.label :title= ป้ายชื่อช่อง (ขึ้นว่า Title) ·form.text_field :title= ช่องกรอกบรรทัดเดียวของ title ·form.textarea= ช่องกรอกหลายบรรทัดif post.errors.any?= "แฟ้มนี้มีโน้ตสีเหลือง (errors บท 110) ไหม" · มีก็วาดกล่องแดงที่ list ทุกข้อ · หน้า new ครั้งแรกไม่มี error กล่องนี้จึงไม่โผล่form.submit= ปุ่มส่ง · แฟ้มนี้ใช้ helper ไปแล้วหนึ่งตัว:pluralizeในหัวกล่องแดง (1 error / 2 errors)- ช่องหมวดเป็น
text_field= ช่องพิมพ์ข้อความ · ผู้ใช้ต้องรู้เองว่าอาหารไทยคือเลข 1 — หัวข้อ 7 จะแก้
เปิดไฟล์จริงจะเห็นช่องแต่ละช่องลงท้ายด้วย class: ["block … w-full", {"border-gray-400 …": post.errors[:title].none?, "border-red-400 …": post.errors[:title].any?}] ·
อ่านว่า "class พื้นฐานชุดแรกใส่เสมอ · ถ้าช่อง title ไม่มี error ใส่ขอบเทา · ถ้ามี error ใส่ขอบแดง" — แค่นั้น ไม่ต้องจำรูปแบบนี้
เปิด server (bin/dev) ทิ้งไว้ แล้วดู HTML ที่เบราว์เซอร์ได้รับจริงของสองหน้า · คำสั่งยาวนี้ = ดึงหน้ามา → กรองเหลือเฉพาะแท็กฟอร์ม → ลบ class ทิ้งให้อ่านง่าย (ก็อปไปวางได้เลย หรือเปิดหน้าในเบราว์เซอร์แล้วกด Ctrl+U / ⌘⌥U ดูซอร์สแทนก็ได้):
$ curl -s localhost:3000/posts/new | grep -oE '<(form|input|textarea|label)[^>]*>' | sed -E 's/ class="[^"]*"//'
<form action="/posts" accept-charset="UTF-8" method="post">
<input type="hidden" name="authenticity_token" value="roV72dSbNORytib2tqzjP5B0dfVVs7JxUJegGxqsUxv3gccn_pjGkJne71ta2CaRYomqlGvYAPErHqE0l2hqXA" />
<label for="post_title">
<input type="text" name="post[title]" id="post_title" />
<label for="post_description">
<textarea rows="4" name="post[description]" id="post_description">
<label for="post_category_id">
<input type="text" name="post[category_id]" id="post_category_id" />
<input type="submit" name="commit" value="Create Post" data-disable-with="Create Post" />
$ curl -s localhost:3000/posts/1/edit | grep -oE '<(form|input|textarea|label)[^>]*>' | sed -E 's/ class="[^"]*"//'
<form action="/posts/1" accept-charset="UTF-8" method="post">
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value="YT93l9aqvj-5s5wsqlz_lu9mcgfi2hwt04wUtLvpLZumWAQW2Y4m1XCulgszWRbt44huGc6PFOO9wdLuJL6lcA" />
<label for="post_title">
<input type="text" value="ต้มยำกุ้งน้ำข้น" name="post[title]" id="post_title" />
<label for="post_description">
<textarea rows="4" name="post[description]" id="post_description">
<label for="post_category_id">
<input type="text" value="1" name="post[category_id]" id="post_category_id" />
<input type="submit" name="commit" value="Update Post" data-disable-with="Update Post" />
วางสองชุดเทียบกัน — โค้ดเดียวกันทุกตัวอักษร แต่ได้ HTML ต่างกันเพราะแฟ้มที่ส่งเข้าไปต่างกัน:
| ส่วนของ HTML | หน้า new (Post.new) | หน้า edit (Post.find(1)) | มาจากไหน |
|---|---|---|---|
action ส่งไปที่ไหน | /posts | /posts/1 |
form_with ถามแฟ้มว่า persisted? (อยู่ในตู้แล้วหรือยัง บท 107) · ยัง = สร้างใหม่ · แล้ว = แก้แผ่นเลข 1 |
ช่องซ่อน _method | ไม่มี (= POST) | value="patch" |
ฟอร์ม HTML ส่งได้แค่ GET/POST · ป้ายซ่อนนี้บอก Rails ว่า "ถือเป็น PATCH นะ" (บท 118) |
value="…" ในช่อง | ไม่มี (ช่องว่าง) | ต้มยำกุ้งน้ำข้น · 1 |
ผู้ช่วย form อ่าน post.title · post.category_id มาเติมให้เอง |
| ปุ่ม | Create Post | Update Post | form.submit ตั้งชื่อตามสถานะของแฟ้ม |
name="post[title]" · authenticity_token | เหมือนกันทั้งสองหน้า | หัวข้อ 6 | |
ส่วนที่เหลือไม่ต้องสนใจ: accept-charset="UTF-8" = ส่งตัวอักษรแบบที่รองรับภาษาไทย ·
data-disable-with = ป้ายสำหรับตัวช่วย JavaScript รุ่นเก่าของ Rails ที่แอปนี้ไม่ได้ติดตั้ง ไม่มีผลอะไร (การกันกดปุ่มซ้ำระหว่างรอ Turbo ทำให้เอง — Turbo เจอในบท 130) · <label for="post_title"> ผูกกับช่อง id="post_title" = คลิกที่ป้ายแล้วเคอร์เซอร์กระโดดไปที่ช่อง
ร้านที่ให้ลูกค้าเขียนใบสั่งบนกระดาษเปล่า พนักงานต้อนรับ (controller) จะอ่านไม่ออกครึ่งหนึ่ง — บางคนเขียน "ชื่อเมนู" บางคนเขียน "หัวข้อ" ·
form_with คือใบสั่งที่พิมพ์ออกจากระบบของร้าน: ทุกช่องมีชื่อตรงกับที่พนักงานต้อนรับรอเป๊ะ (post[title]) ·
แนบบัตรคิวของร้านมาให้แล้ว (authenticity_token · บท 103) · และถ้าเป็นใบแก้ไข ระบบพิมพ์ของเดิมใส่ช่องมาให้แล้ว
ที่ไม่เหมือนกัน: ใบสั่งที่พิมพ์มาสวยแค่ไหน ลูกค้ายังแก้ได้ — เปิดเครื่องมือของเบราว์เซอร์แล้วเพิ่มช่องแปลก ๆ หรือส่งด้วย curl ก็ได้ ·
ใบสั่งจึงไม่ใช่ด่านตรวจ · ด่านจริงคือ params.expect (บท 117) ที่รับเฉพาะช่องในรายการ และ validation ใน model (บท 110) ที่ตรวจค่าตอนบันทึก
เทียบกับ React: ใน Next.js ฟอร์มแก้ไขต้องมี useState (บท 73) หนึ่งตัวต่อช่อง · ดึงค่าเดิมมาใส่ state · ผูก onChange ทุกช่อง · แล้ว fetch ส่ง JSON เอง ·
ใน Rails server พิมพ์ฟอร์มพร้อมค่าเดิมมาเลย เบราว์เซอร์ไม่ต้องจำอะไรเพิ่ม — นี่คือประโยคหลักของ Day 3 ในรูปเล็ก ๆ: "ข้อมูลมีเจ้าของคนเดียวคือ server" (บท 122)
6 · ของฟรีสามอย่างที่ form_with ให้
① บัตรคิวกันใบสั่งปลอม — ช่องซ่อน authenticity_token คือ "บัตรคิว" (รหัส CSRF) จากบท 103 · ฟอร์มไหนไม่แนบบัตรนี้ Rails ตอบ 422 ไม่รับ ·
บท 103 บอกไว้ว่า "ฟอร์มของ Rails แนบให้เอง" — form_with คือตัวที่แนบ · ค่าเปลี่ยนทุกครั้งที่โหลดหน้า
② ชื่อช่อง post[title] ตรงกับที่ controller รอ — ดูบรรทัดท้ายของ app/controllers/posts_controller.rb:
def post_params
params.expect(post: [ :title, :description, :category_id ])
end
params.expect(post: [ … ]) = "ขอกลุ่มชื่อ post ที่มีช่อง title · description · category_id" ·
ช่องชื่อ post[title] ใน HTML คือ "ช่อง title ในกลุ่ม post" พอดีเป๊ะ ·
form_with model: post ตั้งชื่อกลุ่มจากชื่อ model ให้เอง
ถ้าเขียนช่องเองทุกช่องด้วยชื่อเปล่า ๆ เช่น <input name="title"> (ลืมกลุ่ม post[…]) จะเกิดอะไร? ผมยิงใบสั่งแบบนั้นด้วย curl (วิธีถือบัตรคิวแบบบท 117) ได้:
Completed 400 Bad Request in 1ms (ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)
ActionController::ParameterMissing (param is missing or the value is empty or invalid: post):
400 + param is missing … post = "ไม่มีกลุ่ม post มาเลย" · พนักงานต้อนรับไม่รับใบสั่งทั้งใบ · ปล่อยให้ form_with ตั้งชื่อเอง ปัญหานี้ไม่มีทางเกิด
ถ้าลืมแค่ช่องเดียว (ช่องอื่นยังเป็น post[…]) ร้ายกว่า — ไม่ได้ 400 เพราะกลุ่ม post ยังมา แต่ช่องที่ลืมหายเงียบ ๆ ·
ผมลองส่ง title ชื่อเปล่า + post[description] + post[category_id] ได้ 422 พร้อม Title can't be blank ทั้งที่พิมพ์หัวข้อไปแล้ว
③ ค่าเดิมเติมกลับเมื่อบันทึกไม่ผ่าน — ผมเปิดหน้า /posts/new ใน Chrome · พิมพ์แค่หัวข้อ "ลาบหมู" · ไม่กรอกเนื้อหา ไม่ใส่หมวด · กดปุ่ม ·
หน้าเว็บขึ้นกล่องแดง 2 errors prohibited this post from being saved: ตามด้วย Category must exist · Description can't be blank ·
ช่องเนื้อหาขอบแดง · และช่องหัวข้อยังมีคำว่า "ลาบหมู" อยู่ ไม่ต้องพิมพ์ใหม่ · ใน log ของ server:
Started POST "/posts" for ::1 at 2026-11-07 10:39:56 +0700
Parameters: {"authenticity_token" => "[FILTERED]", "post" => {"title" => "ลาบหมู", "description" => "", "category_id" => ""}, "commit" => "Create Post"}
Completed 422 Unprocessable Content in 10ms (Views: 1.5ms | ActiveRecord: 3.0ms (0 queries, 0 cached) | GC: 0.0ms)
และแท็กช่องหัวข้อที่ Chrome ได้รับในรอบนี้:
<input class="block shadow-sm rounded-md border px-3 py-2 mt-2 w-full border-gray-400 focus:outline-blue-600" type="text" value="ลาบหมู" name="post[title]" id="post_title">
for ::1= มาจากเครื่องตัวเอง ·"[FILTERED]"= Rails ซ่อนค่าบัตรคิวไว้ ไม่ให้รหัสลับลงไฟล์ log"post" => {"title" => "ลาบหมู", …}= กลุ่ม post ที่ได้มาจากชื่อช่องpost[…](ข้อ ②)- save ไม่ผ่าน → controller สั่ง
render :newพร้อม 422 โดยใช้@postตัวเดิม ที่ยังถือ "ลาบหมู" กับ errors อยู่ (บท 110) ·form_withอ่าน@post.titleมาใส่value="ลาบหมู"เหมือนหน้า edit — เราไม่ได้เขียนโค้ดเติมค่ากลับเลยสักบรรทัด - error ข้อ
Category must existมาจากbelongs_to :categoryใน model (บท 115) — ไม่ได้มาจากฟอร์ม
- form builder
- ตัวแปร
form(หรือf) ในform_with … do |form|ที่มี method วาดช่อง (text_field·textarea·collection_select) และตั้งชื่อช่องให้ตาม model · พิมพ์บอก AI ได้ว่า "use the form builder (form.text_field), don't hand-write input tags" - param naming (
post[title]) - รูปแบบชื่อช่องที่ Rails รวบเป็นกลุ่ม
params[:post]ให้ · ต้องตรงกับparams.expect(post: […])· ถาม senior ได้ว่า "ได้ 400 ParameterMissing: post — ชื่อช่องในฟอร์มไม่อยู่ในกลุ่ม post[…] ใช่ไหม"
7 · ช่องหมวด — จากพิมพ์เลขเอง เป็นเลือกจากรายการ
ช่องหมวดตอนนี้ให้พิมพ์เลข · ผู้ใช้ไม่มีทางรู้ว่า "ของหวาน" คือเลข 3 · collection_select สร้างช่องเลือก (dropdown) จากรายการในตู้ให้ ·
ผู้ใช้เห็นชื่อหมวด แต่สิ่งที่ส่งไปจริงคือเลข id
ใบสั่งที่มีช่อง "รหัสหมวด: ____" ลูกค้าต้องจำว่าของหวานคือ 3 · ใบสั่งที่ดีกว่าพิมพ์รายชื่อหมวดให้ติ๊ก · ลูกค้าเห็นคำว่า "ของหวาน" ส่วนครัวได้รหัส 3 ไปเหมือนเดิม — พนักงานต้อนรับไม่ต้องเปลี่ยนวิธีอ่านใบสั่งเลย
ที่ไม่เหมือนกัน: รายการบนใบสั่งคือรายการ ณ ตอนพิมพ์หน้า — มีหมวดใหม่เพิ่มทีหลัง ต้องโหลดหน้าใหม่ถึงจะเห็น ·
และลูกค้ายังส่งเลขอื่นมาเองได้ (เช่นด้วย curl) · ด่านที่กันไว้คือ belongs_to — ผมลอง Post.new(…, category_id: 999).valid? ได้ false + Category must exist
เทียบกับ React: ใน Next.js เราต้องดึงรายการหมวดมาเอง แล้ววนสร้าง <option> เองด้วย categories.map(c => …) ·
collection_select ทำทั้งสองอย่างในบรรทัดเดียว และเลือกค่าเดิมให้ด้วย
ใน app/views/posts/_form.html.erb เปลี่ยนบรรทัด form.text_field :category_id, class: [ … ] (บรรทัดยาว) ทั้งบรรทัดเป็น:
<%= form.collection_select :category_id, Category.all, :id, :name,
{ prompt: "เลือกหมวด" },
class: "block shadow-sm rounded-md border border-gray-400 px-3 py-2 mt-2 w-full" %>
| ชิ้น | แปลว่า |
|---|---|
:category_id | ช่องไหนของ post ที่จะเก็บค่าที่เลือก (ได้ชื่อ post[category_id] เหมือนเดิม) |
Category.all | รายการตัวเลือก = ทุกหมวดในตู้ (5 หมวด) |
:id | ค่าที่ส่งไปจริง ของแต่ละตัวเลือก (เรียก category.id) |
:name | ข้อความที่คนเห็น (เรียก category.name) |
{ prompt: "เลือกหมวด" } | ตัวเลือกแรกที่ไม่มีค่า · ไม่มีอันนี้ เบราว์เซอร์จะเลือก "อาหารไทย" (ตัวแรก) ให้เอง ผู้ใช้ลืมเลือก = ได้หมวดผิดเงียบ ๆ |
class: "…" | class ของ Tailwind ใส่ให้แท็ก <select> (ชุดเดียวกับช่องอื่นในฟอร์ม) |
HTML ที่ได้ (คำสั่งกรองแบบเดิม เปลี่ยนเป็นจับแท็ก select/option):
$ curl -s localhost:3000/posts/new | grep -oE '<(select|option)[^>]*>[^<]*' | sed -E 's/ class="[^"]*"//'
<select name="post[category_id]" id="post_category_id">
<option value="">เลือกหมวด
<option value="1">อาหารไทย
<option value="2">ก๋วยเตี๋ยว
<option value="3">ของหวาน
<option value="4">อาหารริมทาง
<option value="5">เครื่องดื่ม
$ curl -s localhost:3000/posts/2/edit | grep -oE '<(select|option)[^>]*>[^<]*' | sed -E 's/ class="[^"]*"//'
<select name="post[category_id]" id="post_category_id">
<option value="1">อาหารไทย
<option selected="selected" value="2">ก๋วยเตี๋ยว
<option value="3">ของหวาน
<option value="4">อาหารริมทาง
<option value="5">เครื่องดื่ม
value="3"+ของหวาน= คนเห็น "ของหวาน" · ส่งpost[category_id]=3ไป · ชื่อช่องยังเหมือนเดิม controller ไม่ต้องแก้อะไร- หน้า edit ของบทความเลข 2 (ก๋วยเตี๋ยวเรือ) มี
selected="selected"ที่ "ก๋วยเตี๋ยว" — form builder อ่านpost.category_idแล้วเลือกให้เอง (ของฟรีข้อ ③ อีกรอบ) - หน้า edit ไม่มี "เลือกหมวด" —
promptโผล่เฉพาะตอนช่องยังไม่มีค่า - ผมลองกับ Chrome: เลือก "ของหวาน" แต่ไม่กรอกเนื้อหา กดส่ง → หน้า 422 กลับมาพร้อมช่องเลือกที่ยังเป็น "ของหวาน"
⚠️ ปีกกา { } ก่อน class: ห้ามหาย · collection_select รับของสองถุง: ถุงแรก "ตัวเลือกของ select" (prompt) ถุงสอง "แอตทริบิวต์ของแท็ก" (class) ·
ผมลองเขียน …, :id, :name, class: "border" %> (ไม่มีถุงแรก) — Rails เอา class ไปใส่ถุงแรก ได้ <select name="post[category_id]" id="post_category_id"> ไม่มี class และไม่มี error สักตัว ·
ไม่ใช้ prompt ก็ต้องใส่ {} ว่าง ๆ ไว้
ถ้าสังเกตว่าในฟอร์มเดิมของ scaffold ช่องหมวดไม่เคยขึ้นขอบแดง แม้มี error Category must exist (ผมลองแล้ว ขอบยังเทา) — ไม่ใช่คุณทำพัง ·
error ข้อนี้เก็บไว้ใต้ชื่อ category (ชื่อ association) แต่ท่อนขอบแดงที่ดูในหัวข้อ 5 เช็ค category_id · Lab บท 126 จะแก้ตรงนี้
collection_select- method ของ form builder ที่สร้าง
<select>จากรายการ object: ช่อง · รายการ · ค่าที่ส่ง · ข้อความที่โชว์ · พิมพ์บอก AI ได้ว่า "use form.collection_select :category_id, Category.all, :id, :name with a prompt instead of a number field" - options vs html_options
- สองถุงท้าย
collection_select(และselect) — ถุงแรกของ Rails (prompt·include_blank) ถุงสองคือแอตทริบิวต์ HTML (class·id) · ถาม senior ได้ว่า "class บน select ไม่ขึ้น ต้องใส่ {} เป็น options ก่อนใช่ไหม"
8 · ทำเองเป็นแล้ว — เขียนเป็นข้อกำหนดให้ AI และตรวจงานมัน
สไลด์หน้า 15 เตือนว่า ถ้าเราไม่รู้ว่ามี truncate · AI มักเขียน [0..80] · ถ้าไม่รู้ว่ามี l() มันจะเขียนรูปแบบวันที่ตายตัว ·
ตอนนี้เราลองเครื่องมือทุกตัวด้วยมือแล้ว ก็เขียนลงในข้อกำหนดของ prompt (ส่วน "ข้อกำหนด" ของ prompt 4 ส่วน บท 89 · ไฟล์ prompts/ ในบท 121):
ข้อกำหนดของ view:
- ตัดข้อความด้วย truncate(…, length: 80, separator: " ") ห้ามใช้ [0..N]
- วันที่ใช้ l(…) หรือ time_ago_in_words ห้าม strftime
- เงินใช้ number_to_currency(…, unit: "฿")
- ช่องในฟอร์มใช้ form.text_field / form.collection_select ห้ามเขียน <input> เอง
- collection_select ใส่ปีกกา { … } (options) ก่อน class: เสมอ
AI เขียนเสร็จแล้ว ตรวจเร็ว ๆ ด้วย grep (ค้นข้อความในทุกไฟล์ของโฟลเดอร์ · -rn = ค้นทุกไฟล์ข้างใน และบอกเลขบรรทัด) — ไม่มีผลขึ้นเลย = ผ่าน · ผมรันทั้งสามคำสั่งกับโค้ดตั้งต้นแล้ว ไม่มีผลขึ้น:
$ grep -rn '\[0\.\.' app/views # เจอ = ตัดข้อความเอง → เปลี่ยนเป็น truncate
$ grep -rn 'strftime' app/views # เจอ = จัดวันที่เอง → เปลี่ยนเป็น l()
$ grep -rn '<input' app/views # เจอ = เขียนช่องฟอร์มเอง → ดูหัวข้อ 6 ว่าทำไมเสี่ยง
\[0\.\. ในบรรทัดแรกหน้าตาแปลกเพราะ [ กับ . มีความหมายพิเศษใน grep ·
ขีด \ ข้างหน้า = "อ่านตัวนี้ตรงตัว" · ทั้งก้อนจึงแปลว่า "หาคำว่า [0.."
🎯 ภารกิจ (~10 นาที) — helper 3 ตัวในหน้า show + ช่องเลือกหมวด
เปิด bin/dev ทิ้งไว้ · แก้ 3 ไฟล์:
app/helpers/posts_helper.rb— ใส่reading_timeตามหัวข้อ 4 (ถ้ายังไม่ได้ทำ)app/views/posts/show.html.erb— ใต้บรรทัด<%= render @post %>เพิ่มย่อหน้าหนึ่งย่อหน้าที่โชว์: "เขียนเมื่อ … ago" (time_ago_in_words) · วันที่เต็มในวงเล็บ (lกับformat: :long) · "อ่านจบใน …" (reading_time)app/views/posts/_form.html.erb— เปลี่ยนช่องหมวดเป็นcollection_selectมี prompt "เลือกหมวด" และ class ตามหัวข้อ 7- เปิด
http://localhost:3000/posts/1แล้วhttp://localhost:3000/posts/new· ที่หน้า new ลองกดส่งโดยไม่เลือกหมวด
ควรเห็น: หน้า show มีบรรทัด "เขียนเมื่อ about 1 month ago (October 08, 2026 03:35) · อ่านจบใน 1 นาที" (คำและวันที่ของคุณต่างตามวันที่รัน seeds) ·
หน้า new มีช่องเลือกที่ขึ้น "เลือกหมวด" ตามด้วย 5 หมวด · ส่งโดยไม่เลือกหมวดได้กล่องแดงที่มี Category must exist
ถ้าเจออาการนี้ แปลว่า…
| เห็นอะไร | แปลว่า |
|---|---|
หน้า error สีแดง undefined method 'reading_tme' for an instance of #<Class:0x…> |
สะกดชื่อ helper ผิด (ผมลองพิมพ์ reading_tme ได้ข้อความนี้ + status 500) · หรือยังไม่ได้ save ไฟล์ helper · ส่วน #<Class:0x…> คือชื่อภายในของ view ไม่ต้องสนใจ |
| ช่องเลือกหมวดไม่มีกรอบ ไม่มีสไตล์ | ลืม { prompt: … } ก่อน class: → class ไปผิดถุง (กล่องเตือนในหัวข้อ 7) |
| ตรงที่ควรมีวันที่ ว่างเปล่า | ใช้ <% %> ลืม = — คำนวณแล้วไม่วางบนจาน (บท 122) |
DevTools ของเบราว์เซอร์ (คลิกขวา → Inspect → แท็บ Console) ขึ้นแดง Failed to load resource: the server responded with a status of 422 (Unprocessable Content) |
ปกติ — คือ validation ไม่ผ่านแล้ว server ตอบ 422 ตามที่ควร (ผมเห็นบรรทัดนี้ใน Chrome ตอนลองเหมือนกัน) |
| เวลาในวงเล็บช้ากว่านาฬิกาไทย 7 ชั่วโมง | แอปยังใช้เวลา UTC · ไม่ใช่บั๊กของคุณ · Lab บท 126 ตั้งเขตเวลาให้ |
# app/helpers/posts_helper.rb
module PostsHelper
def reading_time(text)
"#{(text.to_s.split.size / 200.0).ceil} นาที"
end
end
<%# app/views/posts/show.html.erb — เพิ่มใต้ render @post %>
<%= render @post %>
<p>
เขียนเมื่อ <%= time_ago_in_words(@post.created_at) %> ago
(<%= l(@post.created_at, format: :long) %>)
· อ่านจบใน <%= reading_time(@post.description) %>
</p>
<%# app/views/posts/_form.html.erb — ช่องหมวด %>
<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 border-gray-400 px-3 py-2 mt-2 w-full" %>
</div>
HTML ที่หน้า show ได้จริง (curl -s localhost:3000/posts/1 ตัดมาเฉพาะย่อหน้านี้):
<p>
เขียนเมื่อ about 1 month ago
(October 08, 2026 03:35)
· อ่านจบใน 1 นาที
</p>
- ในไฟล์ view เรียก
time_ago_in_words·l·reading_timeตรง ๆ ไม่มีhelper.นำหน้า (นั่นมีแค่ใน console) - ใน view ใช้
@post(ของบนถาดหน้าครัวที่ controller วางไว้) · ส่วนใน_formใช้postไม่มี @ เพราะ partial รับของผ่าน locals (บท 123) - ไม่ได้ใช้
truncateในหน้า show เพราะหน้านี้ควรโชว์เนื้อหาเต็ม · ที่ของมันคือหน้ารายการ — Lab บท 126 ใช้
จบบทแล้วล้างของที่ทดลอง (Lab บท 126 จะทำช่องหมวดอีกรอบแบบเต็ม เริ่มจากไฟล์ตั้งต้น):
$ git restore app/helpers/posts_helper.rb app/views/posts/show.html.erb app/views/posts/_form.html.erb
$ git status --short
git status --short ไม่ขึ้นอะไรเลย = กลับเป็นโค้ดตั้งต้นแล้ว · ถ้ายังมีชื่อไฟล์ขึ้น แปลว่าแก้ไฟล์อื่นเพิ่มด้วย ให้ใส่ชื่อไฟล์นั้นต่อท้าย git restore
ทดสอบความเข้าใจ
1. ต้องโชว์ต้นเรื่องของบทความภาษาไทยในหน้ารายการ · ข้อไหนจริงที่สุด?
ตัวเลือกที่ 3 ถูก · ผมลองแล้ว truncate(…, length: 40) ได้ …รสเปรี้... (กลางคำ) · ใส่ separator: " " ได้ …ใส่นมข้นจืด...
ตัวเลือกที่ 1 ผิด — Ruby นับเป็นตัวอักษร ไม่มีตัวไหนแตก แต่ไม้เอกหลุดได้ ("ใส่" เป็น "ใส") ·
ตัวเลือกที่ 2 ผิด — truncate ไม่รู้จักคำไทย ต้องอาศัยช่องว่าง ·
ตัวเลือกที่ 4 ผิด — ไม้เอกไม้โทนับเป็นหนึ่งตัวทั้งใน [ ] และ truncate ("ต้..." ยาว 5 ตัวพอดี)
จุดที่ต้องจำ: ภาษาไทยใช้ truncate(text, length: N, separator: " ") · และอย่าเชื่อว่า helper ตัวไหน "รู้จักภาษาไทย" จนกว่าจะลองเอง
2. หน้า edit ของบทความเลข 1 ใช้ form_with(model: post) ตัวเดียวกับหน้า new · แท็กฟอร์มที่เบราว์เซอร์ได้รับหน้าตาแบบไหน?
ตัวเลือกที่ 4 ถูก · ตรงกับ HTML จริงจาก curl ในหัวข้อ 5 · form_with ถาม persisted? ได้ true จึงส่งไป /posts/1 และแปะป้าย PATCH
ตัวเลือกที่ 1 ผิด — นั่นคือหน้า new (แฟ้มที่ยังไม่อยู่ในตู้) ·
ตัวเลือกที่ 2 ผิด — ฟอร์ม HTML ส่งได้แค่ GET กับ POST จึงต้องมีช่องซ่อน _method ·
ตัวเลือกที่ 3 ผิด — /posts/1/edit คือหน้าที่โชว์ฟอร์ม ไม่ใช่ที่รับใบสั่ง และงานที่เปลี่ยนข้อมูลไม่ใช้ GET
จุดที่ต้องจำ: ฟอร์มเดียวพอ — แฟ้มใหม่ไป POST /posts · แฟ้มเก่าไป PATCH /posts/:id · ตัดสินจาก persisted?
3. เขียน reading_time ไว้ใน app/helpers/posts_helper.rb · ถ้าเรียกใน app/views/categories/show.html.erb จะเกิดอะไร?
ตัวเลือกที่ 2 ถูก · ผมเช็คใน console ด้วย CategoriesController.helpers.respond_to?(:reading_time) (= ถามว่า "ลิ้นชักของหน้าหมวดมี reading_time ไหม") ได้ true · ชื่อไฟล์แค่ช่วยจัดหมวด
ตัวเลือกที่ 1 ผิด — ฟังดูมีเหตุผลแต่ไม่ใช่ค่าตั้งต้นของ Rails · ตัวเลือกที่ 3 ผิด — ไม่ต้อง include เอง Rails ทำให้แล้ว · ตัวเลือกที่ 4 ผิด — view ไม่มีการ import แบบ JavaScript · helper ทุกตัวพร้อมใช้ใน view เลย
จุดที่ต้องจำ: helper ทุกตัวใช้ได้ทุกหน้า → ตั้งชื่อให้ชัด อย่าตั้งชื่อกว้าง ๆ ที่อาจชนกับ helper ไฟล์อื่น
4. เพื่อนยังใช้ form_with แต่เขียนช่องเองทุกช่องด้วยชื่อเปล่า ๆ เช่น <input type="text" name="title"> แทน form.text_field :title · กดส่งแล้วได้อะไร?
ตัวเลือกที่ 1 ถูก · ผมส่งใบสั่งที่ไม่มีกลุ่ม post ได้ 400 Bad Request + param is missing or the value is empty or invalid: post
ตัวเลือกที่ 2 ผิด — Rails ไม่เดาใจ params.expect(post: …) ต้องเจอกลุ่ม post ·
ตัวเลือกที่ 3 ผิด — รหัส CSRF เป็นช่องซ่อนของทั้งฟอร์ม (form_with ใส่ไว้แล้ว) ไม่ได้ผูกกับช่องใดช่องหนึ่ง ·
ตัวเลือกที่ 4 ผิด — ไม่ถึงขั้นบันทึกเลย controller หยุดตั้งแต่ post_params
จุดที่ต้องจำ: ให้ form builder ตั้งชื่อช่อง แล้วชื่อจะตรงกับ params.expect เสมอ · เห็น 400 ParameterMissing ให้ดูชื่อช่องก่อน
ถามกลับได้เลย — ถ้าฟอร์มส่งไม่ผ่านแล้วไม่รู้ทำไม ก็อปบรรทัด Parameters: {…} จาก log ของ server กับบรรทัด params.expect ใน controller มาถาม ·
สองอย่างนี้บอกเกือบทุกอย่างว่าชื่อช่องตรงกันหรือเปล่า
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง (Rails 8.1.4) | ต้องสนใจไหม |
|---|---|---|---|
| 15 | text[0..80] "ตัดกลางคำและพังกับภาษาไทย" (ให้ใช้ truncate แทน) |
ตัวอักษรไม่แตก แต่สระ/วรรณยุกต์หลุดได้ · truncate ก็ตัดกลางคำเหมือนกัน · ที่ช่วยจริงคือ ... + separator: " " | ใช่ — ใส่ separator ทุกครั้ง |
| 15 | ไม่รู้ว่ามี l() = AI hardcode รูปแบบวันที่อังกฤษ |
l() ในแอปเราก็ได้อังกฤษ เพราะไม่มีคำแปลไทย · branch day3-a-done ของอาจารย์ติด gem rails-i18n + ตั้ง default_locale = :th ไว้ บทเรียนเราไม่ได้ติด · ข้อดีของ l() คือเปลี่ยนภาษาได้ที่เดียว | ไม่ต้องรีบ |
| 15 | reading_time = จำนวนคำ / 200 |
ภาษาไทยไม่เว้นวรรคระหว่างคำ ได้ "1 นาที" แทบทุกบทความ | ใช้ฝึกเขียน helper ได้ อย่าเชื่อตัวเลข |
| 15 | number_to_currency(99) · image_tag post.cover |
ได้ $99.00 ต้องใส่ unit: "฿" · ตาราง posts ยังไม่มีช่อง cover (รูปปกเป็นเรื่อง Day 3 Part 2) | ใส่ unit · cover ข้ามไปก่อน |
| 16 | ฟอร์มใช้ f.collection_select |
ไฟล์ของ scaffold (และ branch ของอาจารย์) ยังเป็น form.text_field :category_id · ต้องแก้เองตามหัวข้อ 7 | ใช่ ถ้าพิมพ์ตาม |
อ้างอิง
- Rails Guides — Action View Helpers — อ่านหัวข้อ truncate · time_ago_in_words · number_to_currency (รายการ helper ทั้งลิ้นชักพร้อมตัวอย่างสั้น ๆ)
- Rails API — TextHelper#truncate —
ดูตัวเลือก
:separatorและ:omission(เปลี่ยน...เป็นคำอื่น) - Rails Guides — Action View Form Helpers —
อ่าน Creating Forms with Model Objects (new กับ edit) ·
The collection_select Helper ·
Form Input Naming Conventions (ที่มาของ
post[title]) - Rails API — FormOptionsHelper#collection_select —
ดูลำดับ argument:
options = {}มาก่อนhtml_options = {}(ที่มาของกับดักปีกกาในหัวข้อ 7) - Rails Guides — i18n: Adding Date/Time Formats —
วิธีที่
l()หยิบรูปแบบวันที่จากสมุดคำแปล · และ gem rails-i18n ที่ branch ของอาจารย์ใช้