บทเรียน 124 · Rails · Day 3

helper และ form_with — เครื่องมือจัดจาน และฟอร์มที่ตั้งชื่อช่องให้เอง

~25 นาที (รวมภารกิจ 10 นาที) · ควรผ่าน บทเรียน 123 (layout · partial · locals) มาก่อน · ใช้ของจาก บท 103 (link_to) และ บท 118 (อ่านฟอร์มของ scaffold) ด้วย

หน้าเว็บของเราตอนนี้โชว์ข้อความยาวทั้งก้อน โชว์วันที่เป็นตัวเลขดิบ และมีช่อง "หมวด" ที่บังคับให้ผู้ใช้พิมพ์เลข 1–5 เอง
บทนี้คุณจะได้หยิบเครื่องมือสำเร็จรูปของ Rails มาตัดข้อความภาษาไทย บอกเวลา จัดตัวเลข · เขียนเครื่องมือของร้านเองหนึ่งตัว · อ่านออกว่า form_with ตั้งชื่อช่องในฟอร์มให้ยังไง · แล้วเปลี่ยนช่องพิมพ์เลขหมวดให้เป็นช่องเลือกจากรายการ

🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้

🧰 เวอร์ชันที่ใช้ตรวจ: 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)
=> "ต้..."
🗣 ศัพท์ที่เพิ่งเรียนไป
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: " ")
=> "ต้มยำกุ้งแบบน้ำข้นใส่นมข้นจืด..."

✅ สูตรที่ใช้กับภาษาไทย: 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" />

สามเรื่องที่ชอบทำให้งง:

ทำไมเป็นอังกฤษ: 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

ลองใน 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 %>

เปิดไฟล์จริงจะเห็นช่องแต่ละช่องลงท้ายด้วย 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 PostUpdate Postform.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">
🗣 ศัพท์ที่เพิ่งเรียนไป
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">เครื่องดื่ม

⚠️ ปีกกา { } ก่อน 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 ไฟล์:

  1. app/helpers/posts_helper.rb — ใส่ reading_time ตามหัวข้อ 4 (ถ้ายังไม่ได้ทำ)
  2. app/views/posts/show.html.erb — ใต้บรรทัด <%= render @post %> เพิ่มย่อหน้าหนึ่งย่อหน้าที่โชว์: "เขียนเมื่อ … ago" (time_ago_in_words) · วันที่เต็มในวงเล็บ (l กับ format: :long) · "อ่านจบใน …" (reading_time)
  3. app/views/posts/_form.html.erb — เปลี่ยนช่องหมวดเป็น collection_select มี prompt "เลือกหมวด" และ class ตามหัวข้อ 7
  4. เปิด 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)ต้องสนใจไหม
15text[0..80] "ตัดกลางคำและพังกับภาษาไทย" (ให้ใช้ truncate แทน) ตัวอักษรไม่แตก แต่สระ/วรรณยุกต์หลุดได้ · truncate ก็ตัดกลางคำเหมือนกัน · ที่ช่วยจริงคือ ... + separator: " "ใช่ — ใส่ separator ทุกครั้ง
15ไม่รู้ว่ามี l() = AI hardcode รูปแบบวันที่อังกฤษ l() ในแอปเราก็ได้อังกฤษ เพราะไม่มีคำแปลไทย · branch day3-a-done ของอาจารย์ติด gem rails-i18n + ตั้ง default_locale = :th ไว้ บทเรียนเราไม่ได้ติด · ข้อดีของ l() คือเปลี่ยนภาษาได้ที่เดียวไม่ต้องรีบ
15reading_time = จำนวนคำ / 200 ภาษาไทยไม่เว้นวรรคระหว่างคำ ได้ "1 นาที" แทบทุกบทความใช้ฝึกเขียน helper ได้ อย่าเชื่อตัวเลข
15number_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ใช่ ถ้าพิมพ์ตาม

อ้างอิง