🔁 ทวนก่อนเริ่ม — คำจากบทก่อน ๆ ที่บทนี้ใช้
- Turbo Drive (บท 130) — คลิกลิงก์แล้ว Turbo ไปขอหน้าใหม่มาเปลี่ยนเฉพาะจานบนโต๊ะเดิม ไม่รื้อโต๊ะ (
<head>อยู่ที่เดิม) · บทนี้ย่อลงอีกขั้น: เปลี่ยนแค่ถ้วยใบเดียว - partial · locals (บท 123) —
_post.html.erbคือแม่พิมพ์ของบทความหนึ่งชิ้น · ข้างในใช้post(ของที่ยื่นใส่มือ) ไม่ใช่@post· หน้า show เรียกด้วยrender @post, full: true form_with model:(บท 124) — บทความที่มีอยู่แล้ว → ฟอร์มส่งแบบ PATCH ไป/posts/30และตั้งชื่อช่องเป็นpost[title]ให้เอง- validation · 422 (บท 110) — หัวข้อว่าง = บันทึกไม่ได้ · scaffold ตอบกลับด้วย
render :edit, status: :unprocessable_content(เลข 422) พร้อมข้อความ error before_action(บท 118) — "ก่อนเข้า action พวกนี้ ให้ทำ method นี้ก่อน" · scaffold มีbefore_action :set_postอยู่แล้ว
🍜 เริ่มจากโค้ดสถานะไหน: โปรเจกต์ wongnok-blog-all-in-one ตอนจบ Lab บท 129
(มี daisyUI · navbar · ปุ่มสลับธีมแล้ว) · ของที่ทดลองในบท 130 ต้อง git restore ไปแล้ว ·
บทนี้แก้ 2 ไฟล์ + สร้าง 1 ไฟล์ แล้ว commit — บท 132 จะต่อจากตรงนี้
(ตามไม่ทัน: ดูวิธีเปิดโปรเจกต์ในบท 120 แล้วทำ Lab บท 126 · 129 ตามลำดับ)
🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 · turbo-rails 2.0.23 · ทดสอบในเบราว์เซอร์ด้วย Chrome จริง (สไลด์กับ repo ของอาจารย์เขียน Ruby 4.0.5 · Rails 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน)
ผลรันทุกก้อนรันจริงแล้วก็อปมา · ตัวอย่างใช้บทความเลขที่ 30 ("นมเย็น" — ใบแรกบนหน้ารวม) · เลข id · เวลา ms · วันเวลาใน log · รหัสกัน CSRF ยาว ๆ ของคุณจะต่าง ·
path ยาว ๆ ของ gem ย่อเป็น …
ตอนนี้: แก้หัวข้อคำเดียว ต้องย้ายไปอีกหน้า
เปิด http://localhost:3000/posts/30 · ใต้บทความมีปุ่ม "แก้ไข" · กดแล้วเบราว์เซอร์ไป /posts/30/edit
ซึ่งเป็นอีกหน้าหนึ่ง มีฟอร์มสามช่อง (หัวข้อ · เนื้อหา · หมวด) · แก้เสร็จกดบันทึก ถูกส่งกลับมาหน้าเดิม
ใช้ได้ แต่ถ้าแค่อยากแก้คำผิดในหัวข้อ มันอ้อมเกินไป · ที่เราอยากได้คือแก้ตรงที่มันอยู่ — กดปุ่มข้างหัวข้อ → หัวข้อกลายเป็นช่องพิมพ์ → กดบันทึก → กลับเป็นหัวข้อใหม่ · ส่วนอื่นของหน้าอยู่เฉย ๆ
ของที่ทำแบบนี้ได้ชื่อ Turbo Frame — ชิ้นที่สองของ Hotwire (ชิ้นแรกคือ Turbo Drive บท 130) ·
เราเอากล่องพิเศษชื่อ <turbo-frame> ไปครอบส่วนที่อยากให้เปลี่ยนได้ ·
ลิงก์หรือฟอร์มที่อยู่ข้างในกล่อง เมื่อกดแล้ว Turbo จะเปลี่ยนแค่ข้างในกล่องนั้น ไม่แตะส่วนอื่นของหน้าเลย
Turbo Drive (บท 130) เหมือนพนักงานเปลี่ยนจานทั้งชุดบนโต๊ะเดิม · Turbo Frame ละเอียดกว่านั้น: ลูกค้าขอเปลี่ยนน้ำจิ้มถ้วยเดียว · พนักงานเดินไปครัว รับถาดที่ครัวจัดมา แล้วหยิบเฉพาะถ้วยที่ป้ายเลขตรงกับถ้วยบนโต๊ะมาวางแทน · ข้าว กับข้าว แก้วน้ำ อยู่ที่เดิมหมด
ที่ไม่เหมือนกัน: ร้านจริงคงไม่มีใครจัดอาหารทั้งถาดเพื่อเปลี่ยนน้ำจิ้มถ้วยเดียว · แต่ Turbo ขอหน้าเว็บมาทั้งหน้าจริง ๆ แล้วทิ้งส่วนที่เหลือ — เดี๋ยวจะเห็นว่า Rails ช่วยให้ "ถาด" นั้นบางลงได้
เทียบกับ React (บท 73):
ถ้าทำใน React คุณจะเขียน const [editing, setEditing] = useState(false) สลับการ์ดเป็นช่องพิมพ์ ·
ยิง fetch (คำสั่งของ JavaScript ที่ส่งคำขอไปหา server) ส่ง JSON ไป · แล้วเก็บหัวข้อใหม่ไว้ใน state ของเบราว์เซอร์อีกชุด ·
Turbo Frame ไม่มี state ชุดที่สองแบบนั้น — server ส่ง HTML ของกล่องมาให้ทั้งก้อน เบราว์เซอร์แค่เอามาวาง
- Turbo Frame ·
<turbo-frame> - กล่อง HTML ที่ Turbo ดูแล · ลิงก์และฟอร์มข้างในกล่องจะโหลดผลมาใส่แค่ในกล่องนั้น · พิมพ์บอก AI ได้ว่า “wrap the post title in a turbo frame so the edit link loads inside it”
- inline edit
- แก้ของ "ในที่ของมัน" แทนที่จะพาไปหน้าแก้ไขแยก · เป็นงานที่ Turbo Frame เหมาะที่สุด · ถาม senior ได้ว่า “ตรงนี้ทำ inline edit ด้วย Turbo Frame ได้ไหม หรือต้องเปลี่ยนหลายจุดจนต้องใช้ Stream”
Turbo Frame ทำงานยังไง — 5 ขั้น
ก่อนพิมพ์โค้ด ดูภาพรวมก่อน (สไลด์หน้า 42 วาดไว้แบบเดียวกัน) · สมมติหน้า show มีกล่อง id title_post_30 ครอบหัวข้อกับปุ่ม "แก้ไขหัวข้อ":
ขั้น ③ คือหัวใจ (id = ชื่อประจำตัวของแท็ก HTML หนึ่งตัว ห้ามซ้ำในหน้าเดียวกัน) · Turbo ไม่ได้เดาว่าจะเอาส่วนไหนของคำตอบ · มันหากล่องที่ id เหมือนกับกล่องที่ลิงก์อยู่ · เจอ = เอาข้างในมาวาง · ไม่เจอ = พัง · นี่คือกฎข้อเดียวของ Frames: id ต้องตรงกันสองฝั่ง (เดี๋ยวเราจะทำให้พังจริงให้ดู)
งานของเราจึงมี 2 ฝั่ง:
- ฝั่งหน้า show — ครอบหัวข้อ + ปุ่ม "แก้ไขหัวข้อ" ด้วยกล่อง
- ฝั่งคำตอบ — ตอนกดปุ่มนั้น server ต้องตอบกลับมาด้วยกล่อง id เดียวกัน ที่ข้างในเป็นฟอร์มช่องเดียว
ขั้นที่ 1 — ครอบหัวข้อด้วยกล่อง (_post.html.erb)
เปิด bin/dev ทิ้งไว้ก่อน (บท 128 — มันคอยสร้าง CSS ใหม่ทุกครั้งที่เราใช้คลาสใหม่ · บทนี้มีคลาสที่แอปยังไม่เคยใช้ เช่น input-lg)
เปิด app/views/posts/_post.html.erb · ในก้อน <% if full %> (ส่วนที่ใช้ในหน้า show) มีหัวข้ออยู่บรรทัดเดียว:
<% if full %>
<h1 class="card-title text-3xl"><%= post.title %></h1>
เปลี่ยนบรรทัด <h1> นั้นเป็นแบบนี้ (บรรทัด if full กับบรรทัด <p> ข้างล่างไม่ต้องแตะ):
<% if full %>
<%# แก้หัวข้อในหน้าเดิม: id ต้องตรงกับ posts/edit.html+turbo_frame.erb %>
<%= turbo_frame_tag dom_id(post, :title) do %>
<div class="flex items-start justify-between gap-2">
<h1 class="card-title text-3xl"><%= post.title %></h1>
<%= link_to "แก้ไขหัวข้อ", edit_post_path(post), class: "btn btn-ghost btn-sm shrink-0" %>
</div>
<% end %>
<p class="leading-relaxed whitespace-pre-line"><%= post.description %></p>
<%# … %>— โน้ตในไฟล์ (ไม่ขึ้นบนหน้าเว็บ · บท 122) · จดไว้เตือนตัวเองว่ากล่องนี้มีคู่อยู่อีกไฟล์turbo_frame_tag … do…end— helper ของ Turbo ที่สร้างแท็ก<turbo-frame id="…">ครอบทุกอย่างระหว่างdoกับend· ใช้<%=(มีเครื่องหมายเท่ากับ) เพราะต้องการให้กล่องขึ้นบนจานdom_id(post, :title)— helper สร้าง id จากบทความ · ได้title_post_30(ดูของจริงข้างล่าง)flex items-start justify-between gap-2— วางของในแถวเดียวกัน (flex) · ชิดบน (items-start) · หัวข้อชิดซ้าย ปุ่มชิดขวา (justify-between) · เว้นช่องเล็กน้อย (gap-2)link_to "แก้ไขหัวข้อ", edit_post_path(post)— ลิงก์ไป/posts/30/edit· URL เดียวกับปุ่ม "แก้ไข" ข้างล่างเป๊ะ ต่างกันแค่ตัวนี้อยู่ในกล่องbtn btn-ghost btn-sm— ปุ่มของ daisyUI แบบโปร่ง ขนาดเล็ก (บท 128) ·shrink-0— ห้ามบีบปุ่มให้แคบลงเวลาหัวข้อยาว
ลองดูว่า dom_id ให้อะไร · เปิด console ด้วย bin/rails console แล้วพิมพ์ตาม (helper. ข้างหน้า = ขอยืม helper ของ view มาใช้ใน console · ผลจริง):
wongnok-blog-all-in-one(dev):001> helper.dom_id(Post.find(30))
Post Load (0.4ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 30 LIMIT 1 /*application='WongnokBlogAllInOne'*/
=> "post_30"
wongnok-blog-all-in-one(dev):002> helper.dom_id(Post.find(30), :title)
Post Load (1.1ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 30 LIMIT 1 /*application='WongnokBlogAllInOne'*/
=> "title_post_30"
wongnok-blog-all-in-one(dev):003> helper.dom_id(Post.find(29), :title)
Post Load (2.5ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 29 LIMIT 1 /*application='WongnokBlogAllInOne'*/
=> "title_post_29"
dom_id(post)→post_30= ชื่อ model +_+ เลขที่ · บทความแต่ละชิ้นได้ id ไม่ซ้ำกันdom_id(post, :title)→title_post_30— คำที่เติมไปอยู่ข้างหน้า ไม่ใช่ข้างหลัง (ไม่ใช่post_30_title) · จำไว้ เดี๋ยวมีกับดัก
ทำไมต้องเติม :title? เพราะ <article> ที่ครอบบทความทั้งชิ้นใช้ dom_id post = post_30 ไปแล้วตั้งแต่ Lab บท 126 ·
id ในหน้าเว็บหนึ่งหน้าห้ามซ้ำกัน (เหมือนเลขที่บ้าน) · กล่องหัวข้อจึงต้องมีชื่อของตัวเอง
ในภาพร้านอาหาร dom_id คือเครื่องพิมพ์ป้ายเลขถ้วย · ใส่บทความเดียวกัน + คำเติมเดียวกัน ได้ป้ายเดียวกันทุกครั้ง ·
เดี๋ยวขั้นที่ 4 เราจะใช้เครื่องพิมพ์ตัวเดียวกันนี้พิมพ์ป้ายให้ถ้วยฝั่งครัว ป้ายสองฝั่งจึงตรงกันเอง
เปิดหน้า /posts/30 ด้วย curl ดู HTML ที่เบราว์เซอร์ได้รับจริง (ตัดมาเฉพาะช่วงบทความ):
$ curl -s localhost:3000/posts/30
…
--><article id="post_30" class="card bg-base-100 shadow-sm">
…
<turbo-frame id="title_post_30">
<div class="flex items-start justify-between gap-2">
<h1 class="card-title text-3xl">นมเย็น</h1>
<a class="btn btn-ghost btn-sm shrink-0" href="/posts/30/edit">แก้ไขหัวข้อ</a>
</div>
</turbo-frame> <p class="leading-relaxed whitespace-pre-line">นมสีชมพูจากน้ำแดง หวานหอม เด็ก ๆ ชอบที่สุด เติมไข่มุกได้</p>
</div>
</article>
-->ต้นบรรทัด — ท้ายของโน้ต<!-- BEGIN app/views/… -->ที่ Rails โหมด development แปะไว้บอกว่า HTML ช่วงนี้มาจากไฟล์ไหน · ไม่ใช่โค้ดของเรา ข้ามได้<article id="post_30">←dom_id post(ของเดิมจากบท 126)<turbo-frame id="title_post_30">←turbo_frame_tag dom_id(post, :title)· สองตัวนี้ id ไม่ชนกันแล้ว<a … href="/posts/30/edit">แก้ไขหัวข้อ</a>←link_to· เป็นลิงก์ธรรมดาทุกอย่าง ไม่มี JavaScript ติดมา · ที่มันพิเศษเพราะอยู่ใน<turbo-frame>เท่านั้น
turbo_frame_tag- helper ของ turbo-rails ที่เขียน
<turbo-frame id="…">…</turbo-frame>ให้ · พิมพ์บอก AI ได้ว่า “use turbo_frame_tag with dom_id on both sides, don't hardcode the frame id” dom_id- helper สร้าง id ของ HTML จาก record เช่น
post_30· เติมคำได้ (title_post_30) · ใช้ตั้งป้ายเลขถ้วยให้ตรงกันทุกไฟล์ · ถาม senior ได้ว่า “ในหน้านี้มี dom_id(post) ซ้ำกันสองที่ไหม ควรเติม prefix อะไร”
ลองกดตอนนี้เลย — กล่องยังมีแค่ฝั่งเดียว
ยังไม่ต้องทำอีกฝั่ง · รีเฟรชหน้า /posts/30 จะเห็นปุ่ม "แก้ไขหัวข้อ" ทางขวาของหัวข้อ
ตอนนี้หน้า /posts/30/edit ยังเป็นหน้าเต็มแบบเดิม ไม่มีกล่อง title_post_30 อยู่ในนั้นเลย ·
ถ้ากด "แก้ไขหัวข้อ" ตอนนี้ คิดว่าจะเกิดอะไร: (ก) ไปหน้าแก้ไขเต็มตามปกติ · (ข) ไม่เกิดอะไรเลย · (ค) อย่างอื่น?
ผลจริงใน Chrome — (ค): URL ยังเป็น /posts/30 · แต่ตรงหัวข้อกลายเป็นคำว่า Content missing ตัวหนา ·
ข้อความเนื้อหา · navbar · ปุ่มข้างล่าง อยู่เหมือนเดิม · HTML ของกล่องหลังกด:
<turbo-frame id="title_post_30" src="http://localhost:3000/posts/30/edit" complete=""><strong class="turbo-frame-error">Content missing</strong></turbo-frame>
src กับ complete คือของที่ Turbo เติมเองหลังกด (ไปโหลดจากไหนมา · โหลดเสร็จแล้ว) · ข้างในเหลือแค่ป้ายเตือน Content missing
และในแท็บ Console ของ DevTools (บท 130 · Mac: ⌘+⌥+J) มี error สีแดง · บรรทัดแรกที่ผมดักได้จาก Chrome:
The response (200) did not contain the expected <turbo-frame id="title_post_30"> and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.
แปล: "คำตอบ (200 = สำเร็จ) ไม่มีกล่อง title_post_30 ที่รออยู่ จึงไม่เอาอะไรมาวาง" ·
server ตอบปกติดี แต่ Turbo หาถ้วยที่ป้ายเลขตรงกันไม่เจอ — มันเลยไม่ยอมเปลี่ยนทั้งหน้า และเขียนป้ายเตือนไว้ในกล่องแทน
ท่อนท้าย "To perform a full page visit… turbo-visit-control" = คำแนะนำวิธีบังคับให้โหลดทั้งหน้าแทน — บทนี้ไม่ใช้ ข้ามได้ ·
ข้างหน้าข้อความอาจมีชื่อ error แปลก ๆ (ผมเห็นเป็น Nt: เพราะไฟล์ Turbo ถูกบีบให้เล็ก) ไม่ต้องสนใจเช่นกัน
นี่คือกฎข้อเดียวของ Frames ที่เห็นด้วยตาแล้ว · รีเฟรชหน้าเพื่อให้หัวข้อกลับมา · ทีนี้ไปทำอีกฝั่งให้ตรงกัน
ขั้นที่ 2 — ทำไมไม่ครอบหน้า edit เดิมเฉย ๆ
ทางที่ตรงที่สุด (และเป็นทางที่สไลด์หน้า 41 สอน) คือเปิด edit.html.erb แล้วครอบฟอร์มด้วยกล่อง id เดียวกัน ·
สไลด์บอกว่า "controller ไม่ต้องแก้อะไรเลย" — ซึ่งจริงถ้าหน้า edit มีไว้ใช้ในกล่องอย่างเดียว ·
แต่แอปเรายังต้องใช้หน้า edit เต็ม (ปุ่ม "แก้ไข" ข้างล่าง) · ผมลองทำตามสไลด์ตรง ๆ แล้ว ได้ปัญหาสองอย่าง:
- ในกล่องได้ฟอร์ม 3 ช่อง (หัวข้อ · เนื้อหา · หมวด) แทนที่หัวข้อ — ไม่ใช่ "แก้หัวข้อ" แล้ว
- หน้า edit เต็มพัง: เปิด
/posts/30/editตรง ๆ แล้วกดบันทึก → URL ค้างที่/posts/30/edit· ฟอร์มหายไป เหลือหัวข้อ "แก้ไขบทความ" กับชื่อบทความ + ปุ่ม "แก้ไขหัวข้อ" (ของในกล่องจากหน้า show) · ไม่มีข้อความ "บันทึกการแก้ไขแล้ว"
ข้อสองเกิดเพราะฟอร์มที่อยู่ในกล่อง จะส่งแบบ "เปลี่ยนแค่กล่อง" เสมอ ไม่ว่าหน้านั้นจะเปิดมายังไง — บันทึกเสร็จ Turbo หยิบแค่กล่องจากหน้า show มาวางแทนฟอร์ม ส่วนอื่นของหน้า edit ค้างอยู่อย่างนั้น · ทางแก้: แยกเป็นสองไฟล์ — หน้า edit เต็มเหมือนเดิมทุกอย่าง · และไฟล์ฟอร์มเล็กที่ใช้เฉพาะตอนคำขอมาจากกล่อง
แล้ว server จะรู้ได้ยังไงว่าคำขอมาจากกล่อง? Turbo บอกให้เอง · ตอนกดลิงก์ในกล่อง มันแนบป้าย (header — ข้อมูลเสริมที่วิ่งไปกับคำขอ ผู้ใช้มองไม่เห็น) ชื่อ Turbo-Frame ไปด้วย ·
ผมดักคำขอจริงจาก Chrome ตอนกด "แก้ไขหัวข้อ" ได้แบบนี้ (ในเครื่องคุณดูได้ที่ DevTools แท็บ Network → คลิกคำขอ edit → Request Headers):
GET http://localhost:3000/posts/30/edit
accept: text/html, application/xhtml+xml
turbo-frame: title_post_30
x-turbo-request-id: c2cdefa6-4159-4881-8e47-6bb1787cfdaa ← เปลี่ยนทุกครั้ง
GET …/posts/30/edit— URL เดียวกับที่ลิงก์ชี้ไป (GET= ขอดูหน้า)accept: text/html, …— "ขอคำตอบเป็น HTML นะ" (ไม่ใช่ JSON)turbo-frame: title_post_30— ป้ายที่เราสนใจ = "คำขอนี้มาจากกล่องtitle_post_30"x-turbo-request-id— เลขประจำคำขอที่ Turbo ติดเอง · ไม่ต้องสนใจ
ปุ่ม "แก้ไข" ข้างล่าง (อยู่นอกกล่อง) ไม่มีป้าย turbo-frame · URL เดียวกัน แต่ป้ายต่างกัน — server จึงตอบต่างกันได้
พนักงานเสิร์ฟ (Turbo) เดินมาสั่งที่ครัวพร้อมแนบป้ายว่า "สำหรับถ้วย title_post_30" ·
ครัวเห็นป้ายก็ใช้สูตรจานเล็ก (ฟอร์มช่องเดียว) · ลูกค้าที่เดินมาสั่งเองที่เคาน์เตอร์ (เปิด URL ตรง ๆ ไม่มีป้าย) ได้จานเต็มเหมือนเดิม ·
ชื่อเมนูเหมือนกัน (/posts/30/edit) แต่ได้คนละจาน
ที่ไม่เหมือนกัน: ในครัวจริง พ่อครัวเห็นป้ายก็รู้เอง · แต่ใน Rails ป้ายแค่วิ่งมาถึง ไม่มีใครเปลี่ยนสูตรให้ · เราต้องเขียนกฎเองใน controller ว่า "เห็นป้ายนี้ → ใช้ไฟล์ไหน" (3 บรรทัดข้างล่าง)
ขั้นที่ 3 — บอก controller ว่า "เห็นป้ายแล้วใช้สูตรจานเล็ก"
เปิด app/controllers/posts_controller.rb · เพิ่มบรรทัดที่ 3 ใต้ before_action :set_post ที่มีอยู่แล้ว:
class PostsController < ApplicationController
before_action :set_post, only: %i[ show edit update destroy ]
before_action :set_turbo_frame_variant, only: %i[ edit update ]
แล้วเลื่อนลงไปส่วน private · วาง method ใหม่ใต้ set_post (เหนือ post_params):
private
# Use callbacks to share common setup or constraints between actions.
def set_post
@post = Post.find(params.expect(:id))
end
# คำขอที่มาจาก turbo-frame (กด "แก้ไขหัวข้อ" ในหน้า show) → ใช้ edit.html+turbo_frame.erb
# เปิด /posts/1/edit ตรง ๆ ไม่มี header Turbo-Frame → ได้ edit.html.erb หน้าเต็มเหมือนเดิม
def set_turbo_frame_variant
request.variant = :turbo_frame if turbo_frame_request?
end
before_action :set_turbo_frame_variant, only: %i[ edit update ]— ก่อนเข้าedit(เปิดฟอร์ม) และupdate(กดบันทึก) ให้ทำ method นี้ก่อน ·%i[ edit update ]= รายการ symbol[:edit, :update]แบบย่อturbo_frame_request?— helper ของ turbo-rails · ตอบtrueถ้าคำขอมีป้ายTurbo-Frame(เครื่องหมาย?ท้ายชื่อ = method ที่ตอบใช่/ไม่ใช่ · บท 91)request.variant = :turbo_frame—request= ข้อมูลของคำขอที่เพิ่งเข้ามา (Rails เตรียมให้ใช้ใน controller) · บรรทัดนี้บอก Rails ว่า "คำขอนี้เป็นรุ่นพิเศษชื่อ turbo_frame" · ตอนหา view, Rails จะหาไฟล์ที่มี+turbo_frameในชื่อก่อน · ไม่เจอ = ใช้ไฟล์ปกติ โดยไม่ error (จำไว้ ใช้ในภารกิจ)… if …ท้ายบรรทัด — ทำข้างหน้าเฉพาะเมื่อเงื่อนไขจริง (Ruby บท 94) · ไม่มีป้าย = ไม่ตั้งอะไร = ใช้ไฟล์ปกติ- ใส่ทั้ง
editและupdateเพราะตอนบันทึกไม่ผ่าน (หัวข้อว่าง)updateจะrender :edit— ต้องได้ฟอร์มเล็กในกล่องเหมือนเดิม ไม่ใช่หน้าเต็ม
สังเกตว่า action edit กับ update ของ scaffold ไม่ต้องแก้สักตัวอักษร · ป้ายกับ before_action ทำงานแทนหมด
- header
Turbo-Frame - ป้ายที่ Turbo แนบไปกับคำขอที่เกิดในกล่อง · ค่าคือ id ของกล่อง · server ใช้แยกว่า "ขอทั้งหน้า" หรือ "ขอแค่กล่อง" · ถาม senior ได้ว่า “action นี้ควรตอบต่างกันไหมถ้ามี Turbo-Frame header”
- request variant ·
turbo_frame_request? - variant = "รุ่น" ของคำขอ · Rails จะเลือกไฟล์ view ที่ชื่อมี
+รุ่นให้เอง (เช่นedit.html+turbo_frame.erb) · พิมพ์บอก AI ได้ว่า “set request.variant = :turbo_frame when turbo_frame_request? and add edit.html+turbo_frame.erb, keep the full edit page unchanged”
ขั้นที่ 4 — ไฟล์ฟอร์มเล็กในกล่อง (edit.html+turbo_frame.erb)
สร้างไฟล์ใหม่ app/views/posts/edit.html+turbo_frame.erb — ระวังชื่อ: +turbo_frame อยู่ระหว่าง .html กับ .erb
และต้องสะกดตรงกับ :turbo_frame ใน controller ·
edit.html.erb ตัวเดิมไม่ต้องแตะ
<%# ฟอร์มแก้เฉพาะหัวข้อ ใช้ตอนกด "แก้ไขหัวข้อ" ในหน้า show (id ต้องตรงกับ posts/_post.html.erb) %>
<%= turbo_frame_tag dom_id(@post, :title) do %>
<%= form_with model: @post, class: "flex flex-col gap-2" do |form| %>
<%= form.text_field :title, autofocus: true, aria: { label: "หัวข้อ" },
class: ["input input-lg w-full", { "input-error": @post.errors[:title].any? }] %>
<% @post.errors.full_messages_for(:title).each do |message| %>
<p class="text-sm text-error"><%= message %></p>
<% end %>
<div class="flex gap-2">
<%= form.submit "บันทึก", class: "btn btn-primary btn-sm" %>
<%= link_to "ยกเลิก", @post, class: "btn btn-ghost btn-sm" %>
</div>
<% end %>
<% end %>
- บรรทัด 2 — กล่อง id
dom_id(@post, :title)=title_post_30ตรงกับฝั่ง show · ใช้@post(ของบนถาดที่ controller วางไว้) เพราะไฟล์นี้เป็น view ของ action ไม่ใช่ partial (บท 123) - บรรทัด 3 —
form_with model: @post→ ฟอร์ม PATCH ไป/posts/30(บท 124) ·|form|= ชื่อเรียกฟอร์มนี้ข้างใน block (ชื่อเดียวกับที่ scaffold ใช้) ·flex flex-col gap-2= เรียงของจากบนลงล่าง เว้นช่องเล็กน้อย - บรรทัด 4 —
form.text_field :title= ช่องพิมพ์หัวข้อ ·autofocus: true= ให้เคอร์เซอร์ไปรออยู่ในช่องเอง ·aria: { label: "หัวข้อ" }= ป้ายชื่อช่องสำหรับโปรแกรมอ่านหน้าจอของคนตาบอด (เราไม่ใส่form.labelเพราะอยากให้ช่องดูเป็นหัวข้อ) - บรรทัด 5 —
class: [ "…", { "input-error": เงื่อนไข } ]= คลาสในวงเล็บเหลี่ยม: ตัวแรกใส่เสมอ · ตัวใน{ }ใส่เฉพาะเมื่อเงื่อนไขจริง — แบบเดียวกับใน_form.html.erbของ Lab บท 129 ·"input-error":ต้องมีเครื่องหมายคำพูดเพราะชื่อมีขีด-· เงื่อนไข@post.errors[:title].any?= "ช่องหัวข้อมี error อย่างน้อยหนึ่งอันไหม" → มี = ขอบแดง - คลาส daisyUI (บท 128):
input= ช่องพิมพ์ ·input-lg= ขนาดใหญ่ ·w-full(Tailwind) = กว้างเต็มแถว - บรรทัด 7–9 —
full_messages_for(:title)= รายการข้อความ error เฉพาะช่องหัวข้อ (เช่นTitle can't be blank) · วนโชว์ทีละบรรทัดด้วยตัวหนังสือสีแดง (text-error) - บรรทัด 12 —
form.submit "บันทึก"= ปุ่มส่งฟอร์ม ·btn-primary= สีหลักของธีม - บรรทัด 13 —
link_to "ยกเลิก", @post→ ลิงก์ไป/posts/30(หน้า show) · ลิงก์นี้ก็อยู่ในกล่อง Turbo จึงไปหยิบหน้า show มา แล้วเอาแค่กล่องtitle_post_30= หัวข้อกับปุ่ม กลับมาวาง · ไม่ต้องเขียน action ใหม่
ตรวจในเบราว์เซอร์จริง — 4 อย่างที่ต้องเห็น
รีเฟรช /posts/30 แล้วลองตามนี้ (ผมตรวจทุกข้อด้วย Chrome จริง · ผลข้างล่างเป็นของจริง):
① กด "แก้ไขหัวข้อ" — หัวข้อกลายเป็นช่องพิมพ์ที่มีคำว่า "นมเย็น" · เคอร์เซอร์กะพริบอยู่ในช่องเลย · ปุ่ม "บันทึก" กับ "ยกเลิก" อยู่ใต้ช่อง ·
URL ยังเป็น /posts/30 · ผมเช็คใน Chrome แล้ว ของนอกกล่องทั้งหมด (navbar · breadcrumb · เนื้อหา · ปุ่มข้างล่าง) เป็นชิ้นเดิม ไม่ได้ถูกวาดใหม่
log ของ server ตอนกด (ผลจริง · ตัดบรรทัด ↳):
Started GET "/posts/30/edit" for ::1 at 2026-11-07 10:58:40 +0700
Processing by PostsController#edit as HTML
Parameters: {"id" => "30"}
Post Load (1.3ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 30 LIMIT 1 /*action='edit',application='WongnokBlogAllInOne',controller='posts'*/
Rendering layout …/turbo-rails-2.0.23/app/views/layouts/turbo_rails/frame.html.erb
Rendering posts/edit.html+turbo_frame.erb within layouts/turbo_rails/frame
Rendered posts/edit.html+turbo_frame.erb within layouts/turbo_rails/frame (Duration: 3.3ms | GC: 0.0ms)
Rendered layout …/turbo-rails-2.0.23/app/views/layouts/turbo_rails/frame.html.erb (Duration: 5.9ms | GC: 0.0ms)
Completed 200 OK in 9ms (Views: 6.6ms | ActiveRecord: 0.8ms (1 query, 0 cached) | GC: 0.0ms)
Rendering posts/edit.html+turbo_frame.erb— ได้ไฟล์ฟอร์มเล็ก (variant ทำงาน)within layouts/turbo_rails/frame— ไม่ได้ใช้ layoutapplicationของเรา · turbo-rails เห็นป้าย Turbo-Frame แล้วเปลี่ยนไปใช้ layout บาง ๆ ของมันเอง (ไม่มี navbar ไม่มีไฟล์ CSS/JS) เพราะยังไง Turbo ก็เอาแค่ในกล่อง — นี่คือ "ถาดบาง" ที่สัญญาไว้ตอนต้นบท
อยากเห็นของที่ server ส่งกลับมาด้วยตาตัวเอง ใช้ curl แนบป้ายเอง (-H = ใส่ header) · ผลจริง (ตัดรหัสกัน CSRF ยาว ๆ และ script ของ rack-mini-profiler — ตัวจับเวลาที่บท 120 เจอ):
$ curl -s -H "Turbo-Frame: title_post_30" localhost:3000/posts/30/edit
<!-- BEGIN …/turbo-rails-2.0.23/app/views/layouts/turbo_rails/frame.html.erb
--><html>
<head>
<meta name="csrf-param" content="authenticity_token" />
<meta name="csrf-token" content="…" />
</head>
<body>
<!-- BEGIN app/views/posts/edit.html+turbo_frame.erb
--><turbo-frame id="title_post_30">
<form class="flex flex-col gap-2" action="/posts/30" accept-charset="UTF-8" method="post"><input type="hidden" name="_method" value="patch" /><input type="hidden" name="authenticity_token" value="…" />
<input autofocus="autofocus" aria-label="หัวข้อ" class="input input-lg w-full" type="text" value="นมเย็น" name="post[title]" id="post_title" />
<div class="flex gap-2">
<input type="submit" name="commit" value="บันทึก" class="btn btn-primary btn-sm" data-disable-with="บันทึก" />
<a class="btn btn-ghost btn-sm" href="/posts/30">ยกเลิก</a>
</div>
</form></turbo-frame><!-- END app/views/posts/edit.html+turbo_frame.erb -->
…
</body>
</html>
<!-- END …/turbo-rails-2.0.23/app/views/layouts/turbo_rails/frame.html.erb -->
<turbo-frame id="title_post_30">— ป้ายเลขถ้วยตรงกับฝั่ง show ✓method="post"+name="_method" value="patch"— เบราว์เซอร์ส่งได้แค่ GET/POST · Rails เลยแอบใส่ช่องซ่อนบอกว่า "จริง ๆ คือ PATCH" (บท 124)name="authenticity_token"— กุญแจกัน CSRF ที่form_withใส่ให้ฟรี (บท 103 · 124) ·<meta name="csrf-…">ใน<head>บาง ๆ ก็เป็นเรื่อง CSRF เหมือนกัน ·data-disable-withบนปุ่ม = ของที่ Rails แปะให้ปุ่ม submit เอง บทนี้ไม่ได้ใช้- ไม่มี navbar · ไม่มี
<link>ไฟล์ CSS — ทั้งก้อน 2,272 ไบต์ · ส่วนหน้า edit เต็ม (ไม่แนบป้าย) 6,070 ไบต์ และไม่มีคำว่าturbo-frameสักตัว (วัดด้วยcurl -w '%{size_download}'· โหมด development มีโน้ตBEGIN/ENDกับ mini-profiler ปนอยู่ ตัวเลขของคุณจะต่างนิดหน่อย)
② ลบหัวข้อจนว่าง แล้วกด "บันทึก" — ช่องมีขอบแดง · ใต้ช่องขึ้น Title can't be blank สีแดง · ทุกอย่างเกิดในกล่อง URL ยังเป็น /posts/30 · log:
Started PATCH "/posts/30" for ::1 at 2026-11-07 10:58:41 +0700
Processing by PostsController#update as TURBO_STREAM
Parameters: {"authenticity_token" => "[FILTERED]", "post" => {"title" => ""}, "commit" => "บันทึก", "id" => "30"}
…
Rendering posts/edit.html+turbo_frame.erb within layouts/turbo_rails/frame
…
Completed 422 Unprocessable Content in 4ms (Views: 1.0ms | ActiveRecord: 0.9ms (1 query, 0 cached) | GC: 0.0ms)
"post" => {"title" => ""}— ฟอร์มส่งมาแค่ช่องหัวข้อ ·params.expectของ scaffold รับได้ (ช่องอื่นไม่ส่ง = ไม่เปลี่ยน) ·[FILTERED]= Rails ปิดค่ากุญแจ CSRF ไม่ให้โผล่ใน log (บท 118)Completed 422+Rendering posts/edit.html+turbo_frame.erb—render :editของ scaffold เลือกไฟล์ฟอร์มเล็กให้เอง เพราะ before_action ตั้ง variant ไว้แล้วas TURBO_STREAM— Turbo บอก server ว่า "ถ้ามีคำตอบแบบ Turbo Stream ก็รับได้นะ" · เรายังไม่มี (ได้เรียนบท 132) Rails จึงตอบเป็น HTML ปกติ — ตอนนี้ข้ามได้- ใน Console ของเบราว์เซอร์จะมี
Failed to load resource: the server responded with a status of 422 (Unprocessable Content)— ปกติของ validation ไม่ใช่ปัญหาเรื่องกล่อง
③ พิมพ์ "นมเย็นสูตรโบราณ" แล้วกด "บันทึก" — กล่องกลับเป็นหัวข้อตัวใหญ่ "นมเย็นสูตรโบราณ" + ปุ่ม "แก้ไขหัวข้อ" · URL ยังเป็น /posts/30 · log ย่อ:
Started PATCH "/posts/30" for ::1 at 2026-11-07 10:58:41 +0700
…
Post Update (2.4ms) UPDATE "posts" SET "title" = 'นมเย็นสูตรโบราณ', "updated_at" = '2026-11-07 03:58:41.734534' WHERE "posts"."id" = 30 …
…
Redirected to http://localhost:3000/posts/30
Completed 303 See Other in 8ms (ActiveRecord: 3.9ms (2 queries, 0 cached) | GC: 0.0ms)
Started GET "/posts/30" for ::1 at 2026-11-07 10:58:41 +0700
…
Rendering posts/show.html.erb within layouts/turbo_rails/frame
…
Completed 200 OK in 5ms (Views: 2.1ms | ActiveRecord: 1.1ms (2 queries, 0 cached) | GC: 0.0ms)
บันทึกสำเร็จ → update สั่ง redirect_to @post เหมือนเดิม (303 See Other = "ไปดูหน้านี้แทน" · บท 118) → Turbo ตามไปขอหน้า show (ยังติดป้าย Turbo-Frame) → เอาแค่กล่อง title_post_30 มาวาง ·
(เวลาใน UPDATE ช้ากว่าใน log 7 ชั่วโมงเพราะฐานข้อมูลเก็บเป็นเวลาสากล UTC — ไม่ใช่บั๊ก)
แต่สังเกตให้ดี — ในหน้าจอเดียวกันนั้น:
- breadcrumb ข้างบน (บทความทั้งหมด › เครื่องดื่ม › นมเย็น) — ยังเป็นชื่อเก่า
- ชื่อแท็บของเบราว์เซอร์ — ยังเป็น "นมเย็น"
- ข้อความเขียว "บันทึกการแก้ไขแล้ว" — ไม่ขึ้น
ไม่ใช่บั๊ก · ทั้งสามอย่างอยู่นอกกล่อง และ Turbo Frame เปลี่ยนได้แค่ในกล่องเดียว ตามที่ออกแบบไว้ · รีเฟรชหน้าแล้วทุกอย่างตรง (ข้อมูลใน DB ถูกแล้ว) · ถ้าอยากให้หลายจุดเปลี่ยนพร้อมกัน ต้องใช้อีกเครื่องมือหนึ่ง — Turbo Streams ในบท 132
④ กด "แก้ไขหัวข้อ" อีกครั้ง แล้วกด "ยกเลิก" — กล่องกลับเป็นหัวข้อ "นมเย็นสูตรโบราณ" + ปุ่มเหมือนเดิม · URL ไม่เปลี่ยน
⚠️ ช่องพิมพ์ไม่ใหญ่ (สูงเท่าช่องธรรมดา)? แปลว่าไม่ได้เปิด bin/dev — คลาสใหม่ input-lg ยังไม่ถูกสร้างใน CSS
(ผมลองแล้ว: ช่องสูง 40px แทน 48px · ส่วนข้อความ error ยังแดงอยู่ เพราะ CSS เดิมมี text-error อยู่แล้ว) ·
เปิด bin/dev หรือสั่ง bin/rails tailwindcss:build แล้วรีเฟรช (บท 128)
ของแถม: เปิด /posts/30/edit ตรง ๆ ยังได้หน้าเต็ม
ลองสองทาง: กดปุ่ม "แก้ไข" ข้างล่างบทความ (อยู่นอกกล่อง) · หรือพิมพ์ localhost:3000/posts/30/edit ที่ช่อง URL ·
ทั้งสองทางได้หน้าแก้ไขเต็มแบบเดิมทุกอย่าง: URL เปลี่ยนเป็น /posts/30/edit · มี navbar · ฟอร์มสามช่อง · ในหน้าไม่มี <turbo-frame> เลย · log บอกว่า:
Processing by PostsController#edit as HTML
…
Rendering layout layouts/application.html.erb
Rendering posts/edit.html.erb within layouts/application
…
Completed 200 OK in 8ms (Views: 3.8ms | ActiveRecord: 1.8ms (2 queries, 0 cached) | GC: 0.0ms)
คำขอไม่มีป้าย → before_action ไม่ตั้ง variant → ได้ edit.html.erb กับ layout application ของเราตามปกติ ·
ความสามารถแบบนี้มีชื่อเรียกว่า progressive enhancement — หน้าเว็บทำงานได้ครบด้วยลิงก์และฟอร์มธรรมดาก่อน · Turbo เป็น "ของแถม" ที่ทำให้ลื่นขึ้น ·
ถ้า JavaScript โหลดไม่ขึ้น หรือมีคนเปิด URL ตรง ๆ ทุกอย่างยังใช้ได้
ร้านนี้มีบริการเปลี่ยนถ้วยถึงโต๊ะ (Turbo Frame) ซึ่งสะดวก · แต่ถ้าวันไหนพนักงานไม่อยู่ (JavaScript ไม่ทำงาน) หรือลูกค้าเดินไปที่เคาน์เตอร์เอง (เปิด URL ตรง ๆ) ก็ยังสั่งอาหารได้ครบจาน · บริการถึงโต๊ะเป็นชั้นที่เพิ่มบนร้านที่ใช้งานได้อยู่แล้ว
ที่ไม่เหมือนกัน: ร้านจริงเคาน์เตอร์กับพนักงานเป็นคนละช่องทาง · ในเว็บใช้ "เมนู" เดียวกันเป๊ะ (/posts/30/edit) ·
ที่ได้คนละจานเพราะป้าย Turbo-Frame ล้วน ๆ — ถ้าเราลืมทำหน้าเต็มไว้ (แบบที่สไลด์สอน) "เคาน์เตอร์" จะพังโดยไม่มีใครเห็นจนกว่าจะมีคนเปิด URL ตรง ๆ
- progressive enhancement
- สร้างหน้าให้ใช้ได้ด้วย HTML ธรรมดาก่อน แล้วค่อยเสริมความลื่นด้วย JavaScript · ถ้าชั้นเสริมพัง ชั้นล่างยังทำงาน · ถาม senior ได้ว่า “หน้านี้ยัง progressively enhanced อยู่ไหม — เปิด URL ตรง ๆ แล้วใช้ได้ครบหรือเปล่า”
★ กฎข้อเดียว: id ต้องตรงสองฝั่ง — ลองทำให้พัง
สไลด์หน้า 42 บอกว่านี่คือบั๊กอันดับ 1 ของคนเริ่มใช้ Frames · สาเหตุที่เจอบ่อย: ฝั่งหนึ่งใช้ dom_id อีกฝั่งพิมพ์ id เอง ·
ลองดู — สมมติคุณเดาว่า id น่าจะเป็น "post_30_title" แล้วพิมพ์เองในไฟล์ฟอร์มเล็ก (บรรทัด 2):
<%= turbo_frame_tag "post_#{@post.id}_title" do %>
"post_#{@post.id}_title" = ต่อ string เอง (#{…} = เอาค่ามาแทรก · Ruby บท 91) ได้ post_30_title · ผลจริงเมื่อกด "แก้ไขหัวข้อ":
$ curl -s -H "Turbo-Frame: title_post_30" localhost:3000/posts/30/edit | grep turbo-frame
--><turbo-frame id="post_30_title">
</form></turbo-frame><!-- END app/views/posts/edit.html+turbo_frame.erb -->
- server ตอบ 200 ปกติ ไม่มี error ใน log เลย · กล่องที่ส่งมาชื่อ
post_30_title - แต่ในหน้า show กล่องชื่อ
title_post_30→ Turbo หาไม่เจอ → ตรงหัวข้อขึ้น Content missing · Console ขึ้นข้อความเดียวกับตอนทำฝั่งเดียว:The response (200) did not contain the expected <turbo-frame id="title_post_30"> and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload. - อ่านให้เป็น:
expected <turbo-frame id="title_post_30">= id ที่ หน้าที่เปิดอยู่รออยู่ · แล้วไปดูว่า server ส่ง id อะไรมา (ด้วยcurl … | grep turbo-frameแบบข้างบน) · สองตัวนี้ต้องเหมือนกันทุกตัวอักษร
ถ้วยบนโต๊ะติดป้าย "title_post_30" · ครัวจัดถ้วยใหม่มาแต่ติดป้าย "post_30_title" · พนักงานถือถาดมาถึงโต๊ะ หาถ้วยป้ายตรงกันไม่เจอ · เลยไม่วางอะไรเลย แปะโน้ต "Content missing" ไว้ในถ้วยเดิม แล้วจดเรื่องนี้ลงสมุดปัญหา (Console)
ที่ไม่เหมือนกัน: พนักงานจริงเห็นว่าป้ายคล้าย ๆ กัน คงเดาได้ว่าถ้วยนี้แหละ · Turbo ไม่เดาเลย — ต่างกันตัวเดียวก็ถือว่าไม่เจอ · และครัวก็ไม่รู้ตัวด้วย (log ฝั่ง server ขึ้น 200 OK ปกติ) อาการจึงไปโผล่ที่เบราว์เซอร์อย่างเดียว
ทางกันที่ดีที่สุด: ใช้ dom_id(…, :title) ทั้งสองฝั่ง ห้ามพิมพ์ id เอง · คนพิมพ์เองเดาลำดับผิดได้ (อย่างที่เพิ่งเห็น — Rails เอาคำเติมไว้ข้างหน้า) ·
helper ตัวเดียวกัน ได้คำตอบเดียวกันเสมอ · และคอมเมนต์บรรทัดแรกของทั้งสองไฟล์ช่วยเตือนว่ากล่องนี้มีคู่ — แก้ฝั่งหนึ่งต้องไปดูอีกฝั่ง
อย่าลืมแก้บรรทัด 2 กลับเป็น dom_id(@post, :title) ก่อนไปต่อ · ลองกดอีกที หัวข้อต้องกลายเป็นช่องพิมพ์ได้ตามปกติ
หนีออกจากกล่อง และของอื่นที่ Frames ทำได้
บางทีลิงก์อยู่ในกล่อง แต่เราอยากให้มันเปลี่ยนทั้งหน้า · ตัวอย่าง: เพิ่มลิงก์ "เปิดฟอร์มเต็ม" ไว้ข้างปุ่มยกเลิก เผื่อคนอยากแก้เนื้อหาด้วย ·
ถ้าเขียนแบบธรรมดา (ลองแล้วค่อย git restore ทิ้งตอนท้าย):
<%= link_to "เปิดฟอร์มเต็ม", edit_post_path(@post), class: "btn btn-link btn-sm" %>
ผลจริง: กดแล้วไม่ไปไหน — URL ยังเป็น /posts/30 และกล่องยังเป็นฟอร์มช่องเดียวเหมือนเดิม ·
เพราะลิงก์อยู่ในกล่อง → ติดป้าย Turbo-Frame → controller ให้ฟอร์มเล็กอีกรอบ · วนอยู่ในกล่อง
ต้องบอก Turbo ว่า "ลิงก์นี้ให้เปลี่ยนทั้งหน้า" ด้วย data: { turbo_frame: "_top" }:
<%= link_to "เปิดฟอร์มเต็ม", edit_post_path(@post), class: "btn btn-link btn-sm",
data: { turbo_frame: "_top" } %>
HTML ที่ได้จริง (ดึงจากหน้าใน Chrome):
<a class="btn btn-link btn-sm" data-turbo-frame="_top" href="/posts/30/edit">เปิดฟอร์มเต็ม</a>
data: { turbo_frame: "_top" }→ กลายเป็น attributedata-turbo-frame="_top"(Rails เปลี่ยน_เป็น-ให้เอง)_top= "ชั้นบนสุด" คือทั้งหน้า ไม่ใช่กล่องไหน- ผลจริงเมื่อกด: URL เปลี่ยนเป็น
/posts/30/edit· ได้หน้าแก้ไขเต็ม ฟอร์มสามช่อง · และwindow.markerที่ผมตั้งไว้ก่อนกด (วิธีเดียวกับบท 130)ยังอยู่ = Turbo Drive เป็นคนเปลี่ยนหน้า ไม่ได้โหลดใหม่ทั้งหมด
ลองแล้วลบสองบรรทัดนั้นทิ้ง — โค้ดของบทนี้ไม่มีลิงก์นี้ · ของอื่นที่ Frames ทำได้ (สไลด์หน้า 43) — รู้จักไว้ก่อน ยังไม่ต้องพิมพ์:
| อยากได้ | เขียนแบบไหน | ภาษาคน |
|---|---|---|
| ลิงก์ในกล่องไปเปลี่ยนกล่องอื่น | data: { turbo_frame: "sidebar" } | ใส่ id ของกล่องปลายทางแทน _top · กล่องนั้นต้องมีอยู่ในหน้า และคำตอบต้องมีกล่อง id นั้น (กฎเดิม) |
| ส่วนที่ช้า ให้โหลดทีหลัง (lazy frame) | <turbo-frame id="comments" src="…" loading="lazy"> + ข้อความ "กำลังโหลด…" ข้างใน | หน้าหลักขึ้นก่อน · กล่องไปขอ src เองตอนเลื่อนมาเห็น · ไม่ต้องเขียน JS · ตัวอย่างในสไลด์ใช้คอมเมนต์ ซึ่งยังไม่มีจนถึงบท 132 |
data-turbo-frame·_top- บอกว่าลิงก์/ฟอร์มนี้ให้ไปเปลี่ยนกล่องไหน ·
_top= ทั้งหน้า · ใส่ id = กล่องนั้น · พิมพ์บอก AI ได้ว่า “this link is inside a turbo frame but must navigate the whole page, add data-turbo-frame=_top” - lazy frame (
src+loading="lazy") - กล่องที่ไปโหลดเนื้อหาเองทีหลัง ·
loading="lazy"= รอให้เลื่อนมาเห็นก่อนค่อยโหลด · ถาม senior ได้ว่า “ส่วนนี้ช้า ทำเป็น lazy turbo frame ดีไหม”
Frames เหมาะกับอะไร ไม่เหมาะกับอะไร
| เหมาะ ✓ | ไม่เหมาะ ✗ → ใช้อะไรแทน |
|---|---|
| inline edit — แก้ของในที่ของมัน (ที่เพิ่งทำ) | ต้องเปลี่ยนหลายที่พร้อมกัน เช่น หัวข้อ + breadcrumb + ข้อความ "บันทึกแล้ว" → Turbo Streams บท 132 |
| แท็บ หรือ modal (หน้าต่างที่เด้งทับหน้า) ที่เนื้อหามาจาก server | ต้องเปลี่ยนของนอกกล่อง เช่น ตัวเลขบน navbar → Turbo Streams บท 132 |
| ส่วนที่ช้า ให้โหลดทีหลัง (lazy) | ของที่ไม่ต้องคุยกับ server เลย เช่น เปิด/ปิดเมนู · นับตัวอักษรที่พิมพ์ → Stimulus บท 134 |
| ฟอร์มค้นหาที่อัปเดตผลลัพธ์ในกล่อง | เด้งของใหม่ขึ้นทุกแท็บที่เปิดอยู่ → broadcast บท 133 |
หลักจำง่าย: กล่องเดียว เปลี่ยนตามที่ผู้ใช้กด → Frame · ผลของสิ่งที่กดกระทบหลายจุด → Stream · ไม่ต้องถาม server → Stimulus
ให้ AI ทำ แล้วตรวจยังไง
ตอนนี้คุณทำเองเป็นแล้ว · ครั้งหน้าจะสั่ง AI ทำ inline edit ช่องอื่นก็ได้ แต่ต้องตรวจเป็น · prompt สั้น ๆ แบบ 4 ส่วน (บท 121):
เป้าหมาย: ในหน้า show ของ Post ให้กด "แก้ไขหัวข้อ" แล้วแก้หัวข้อได้ในหน้าเดิมด้วย Turbo Frame
ข้อกำหนด: ใช้ turbo_frame_tag dom_id(post, :title) ทั้งสองฝั่ง · ฟอร์มเล็กอยู่ใน edit.html+turbo_frame.erb
ตั้ง request.variant = :turbo_frame เมื่อ turbo_frame_request? (เฉพาะ edit, update)
ห้าม: แก้ edit.html.erb หน้าเต็ม · พิมพ์ id ของกล่องเอง · เพิ่ม JavaScript
เกณฑ์ตรวจรับ: ดู checklist ข้างล่าง
checklist ตรวจรับ (ทำเองได้ใน 3 นาที):
grep -rn 'turbo_frame_tag "' app/views(ค้นคำในไฟล์ · บท 125) → ต้องไม่เจออะไร (เจอ = มีคนพิมพ์ id เองเป็น string)- กด "แก้ไขหัวข้อ" → URL ไม่เปลี่ยน · Console ไม่มีข้อความ
did not contain the expected <turbo-frame - ส่งหัวข้อว่าง → error ขึ้นในกล่อง · log เป็น
422และ renderedit.html+turbo_frame.erb - เปิด
/posts/30/editตรง ๆ แล้วบันทึก → URL ไป/posts/30และขึ้น "บันทึกการแก้ไขแล้ว" (หน้าเต็มไม่พัง) git diff→edit.html.erbต้องไม่ถูกแตะ
ข้อ 4 คือจุดที่ AI พลาดบ่อยที่สุดเพราะมันทำตามแบบในบทความทั่วไป (ครอบหน้า edit ทั้งหน้า) · repo ของอาจารย์ (branch day3-c-done) ก็ใช้วิธี variant แบบเดียวกับเรา
(หน้าตาไฟล์ต่างไปนิดหน่อย เช่น เขียน turbo_frame_tag post, :title ซึ่งได้ id เดียวกัน)
เมื่อทุกข้อผ่าน บันทึกงาน (ลบลิงก์ "เปิดฟอร์มเต็ม" ที่ลองไว้ออกก่อน ถ้ายังอยู่):
git status # ต้องเห็น 2 ไฟล์ที่แก้ (posts_controller.rb · _post.html.erb) + 1 ไฟล์ใหม่ (edit.html+turbo_frame.erb)
git add .
git commit -m "แก้หัวข้อบทความในหน้าเดิมด้วย Turbo Frame"
🎯 ภารกิจ — ทำให้ id ไม่ตรง แล้วอ่านอาการเอง (~10 นาที)
ทำหลัง commit แล้ว (จะได้ใช้ git diff ตรวจว่าแก้กลับครบ) · เปิด bin/dev ไว้
- เปิด
app/views/posts/edit.html+turbo_frame.erb· บรรทัด 2 ลบ, :titleออก ให้เหลือturbo_frame_tag dom_id(@post) do(นี่คือความผิดที่เจอบ่อย — ลืมคำเติม) · บันทึกไฟล์ - เปิด
http://localhost:3000/posts/30· เปิด DevTools แท็บ Console · กด "แก้ไขหัวข้อ" - จดสามอย่าง: ในกล่องขึ้นอะไร · URL เป็นอะไร · Console บอกว่า "expected" id อะไร
- ในเทอร์มินัลอีกหน้าต่าง ดูว่า server ส่งกล่อง id อะไรมา:
curl -s -H "Turbo-Frame: title_post_30" localhost:3000/posts/30/edit | grep turbo-frame - แก้บรรทัด 2 กลับเป็น
dom_id(@post, :title)· สั่งgit diff→ ต้องไม่มีอะไรขึ้นเลย · กด "แก้ไขหัวข้อ" อีกครั้ง ต้องได้ช่องพิมพ์
ก่อนกดเฉลย: ขั้น 4 คิดว่า server ส่ง id อะไรมา? และในหน้านี้มี element ไหนที่ใช้ id นั้นอยู่แล้ว?
สิ่งที่ควรเห็น (ผลจริง):
- ในกล่อง: คำว่า Content missing ตัวหนา แทนหัวข้อ · URL ยังเป็น
/posts/30 - Console:
The response (200) did not contain the expected <turbo-frame id="title_post_30"> and will be ignored. … - curl:
--><turbo-frame id="post_30"> </form></turbo-frame><!-- END app/views/posts/edit.html+turbo_frame.erb -->
หน้ารอ title_post_30 แต่ได้ post_30 · ซึ่งบังเอิญเป็น id ของ <article> ที่ครอบบทความทั้งชิ้นในหน้า show ด้วย ·
แต่ Turbo ไม่สนใจ <article> เลย มันหาแค่ <turbo-frame id="title_post_30"> ในคำตอบเท่านั้น ·
วิธีอ่านอาการนี้ใช้ได้ทุกครั้ง: เอา id ใน "expected" ของ Console มาเทียบกับ id ที่ curl … | grep turbo-frame เห็น
ถ้าเจออาการนี้ แปลว่า…
- Content missing ทั้งที่ id ตรงกันทั้งสองไฟล์ — ชื่อไฟล์ variant ผิด (เช่น
edit.html+turboframe.erb) หรือลืมbefore_action :set_turbo_frame_variant· Rails ไม่ error แต่ไปใช้edit.html.erbซึ่งไม่มีกล่อง (ผมลองชื่อไฟล์ผิดแล้ว ได้ Content missing ข้อความเดียวกันเป๊ะ) · ดู log: ถ้าเห็นRendering posts/edit.html.erbแทนedit.html+turbo_frame.erb= ใช่เลย - กดแล้วทั้งหน้าเปลี่ยนไป
/posts/30/edit— คุณกดปุ่ม "แก้ไข" ข้างล่าง (อยู่นอกกล่อง) ไม่ใช่ "แก้ไขหัวข้อ" - curl ได้หน้ายาว ๆ ไม่มีคำว่า turbo-frame เลย — ลืม
-H "Turbo-Frame: title_post_30"· ไม่มีป้าย = ได้หน้าเต็ม (ตรงตามที่ควรเป็น) git diffยังมีบรรทัดขึ้น — แก้กลับไม่ครบ หรือเว้นวรรคต่างจากเดิม · สั่งgit restore app/views/posts/edit.html+turbo_frame.erbได้เลย
กด "แก้ไขหัวข้อ" แล้วในกล่องขึ้นคำว่า Content missing · log ของ server ขึ้น Completed 200 OK · สาเหตุที่น่าจะเป็นที่สุดคืออะไร?
ตัวเลือกที่ 3 ถูก — server ตอบสำเร็จ (200) แต่ในคำตอบไม่มี <turbo-frame> id ที่หน้ารออยู่ · Turbo จึงไม่วางอะไรและเขียน Content missing ไว้ในกล่อง
ตัวเลือกที่ 1 ผิด เพราะ log บอก 200 OK ชัด ๆ · ถ้า server พังจะเป็น 500 · ตัวเลือกที่ 2 ผิด เพราะถ้า Turbo ยังไม่ทำงาน ลิงก์จะพาไปหน้า edit เต็ม (progressive enhancement) ไม่ใช่ขึ้น Content missing · ตัวเลือกที่ 4 ผิด เพราะบทความที่ไม่มีจริงจะได้ 404 ไม่ใช่ 200
จุดที่ต้องจำ: Content missing + 200 OK = id สองฝั่งไม่ตรง · เทียบ id ใน Console กับ id ที่ curl … | grep turbo-frame เห็น
ปุ่ม "แก้ไขหัวข้อ" (ในกล่อง) กับปุ่ม "แก้ไข" ข้างล่าง (นอกกล่อง) ลิงก์ไป /posts/30/edit เหมือนกัน · ทำไมได้ผลต่างกัน?
ตัวเลือกที่ 1 ถูก — Turbo แนบ header Turbo-Frame: title_post_30 เฉพาะคำขอที่เกิดในกล่อง · turbo_frame_request? เป็นจริง → variant → edit.html+turbo_frame.erb
ตัวเลือกที่ 2 ผิด เพราะทั้งคู่เข้า action edit เดิม (log เขียน PostsController#edit ทั้งสองครั้ง) · Turbo ไม่สร้าง action ให้ ·
ตัวเลือกที่ 3 ผิด เพราะลิงก์นอกกล่องก็ยังใช้ Turbo Drive ตามปกติ แค่เปลี่ยนทั้งหน้า ·
ตัวเลือกที่ 4 ผิด เพราะ log แสดงว่ามีคำขอไปถึง server จริงทั้งสองแบบ และได้คนละไฟล์
จุดที่ต้องจำ: URL เดียวกัน แต่ "อยู่ในกล่องหรือไม่" ทำให้ป้ายต่างกัน → server ตอบต่างกันได้
แก้หัวข้อในกล่องสำเร็จ หัวข้อใหม่โผล่แล้ว · แต่ breadcrumb ข้างบนยังเป็นชื่อเก่า และไม่มีข้อความ "บันทึกการแก้ไขแล้ว" · ข้อไหนอธิบายถูก?
ตัวเลือกที่ 4 ถูก — Turbo เอาแค่ข้างในกล่อง title_post_30 มาวาง · ส่วนอื่นของคำตอบทิ้งหมด รวมทั้ง breadcrumb กับข้อความ notice
ตัวเลือกที่ 1 ผิด เพราะ log มี Post Update … UPDATE "posts" แล้ว · รีเฟรชก็เห็นชื่อใหม่ทุกที่ ·
ตัวเลือกที่ 2 ผิด เพราะ breadcrumb ใช้ @post.title คอลัมน์เดียวกัน · แค่ไม่ได้ถูกวาดใหม่ ·
ตัวเลือกที่ 3 ผิด เพราะไม่เกี่ยวกับ cache — breadcrumb ไม่เคยถูกขอใหม่เลยตั้งแต่แรก
จุดที่ต้องจำ: Frame = เปลี่ยนกล่องเดียว · อยากให้หลายจุดเปลี่ยนพร้อมกัน → Turbo Streams (บท 132)
ในฟอร์มเล็กในกล่อง อยากมีลิงก์ "เปิดฟอร์มเต็ม" ที่พาไปหน้า /posts/30/edit แบบทั้งหน้า · ต้องทำอย่างไร?
ตัวเลือกที่ 2 ถูก — data-turbo-frame="_top" บอก Turbo ว่าลิงก์นี้เปลี่ยนทั้งหน้า · ผลจริง: URL ไป /posts/30/edit ได้ฟอร์มสามช่อง
ตัวเลือกที่ 1 ผิด เพราะ loading: "lazy" เป็นเรื่องของกล่องที่โหลดเนื้อหาเองทีหลัง ไม่เกี่ยวกับลิงก์ ·
ตัวเลือกที่ 3 ผิด เพราะลิงก์จะยังอยู่ในกล่อง (กล่องใหม่) ก็ยังไม่เปลี่ยนทั้งหน้า และคำตอบต้องมีกล่อง id นั้นด้วย ·
ตัวเลือกที่ 4 ผิด เพราะทำให้ปุ่ม "แก้ไขหัวข้อ" พังทั้งหมด — ได้ Content missing (ไม่มีไฟล์ไหนตอบกล่องกลับมา)
จุดที่ต้องจำ: ลิงก์ในกล่อง = เปลี่ยนในกล่องเสมอ · อยากหนีออกทั้งหน้า → _top
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง (turbo-rails 2.0.23) | ต้องสนใจไหม |
|---|---|---|---|
| 41 | ครอบฟอร์มในหน้า edit ด้วย <turbo-frame> · "controller ไม่ต้องแก้อะไรเลย" | ทำตามแล้วในกล่องได้ฟอร์ม 3 ช่อง และหน้า edit เต็มพัง (บันทึกแล้ว URL ค้าง /edit) · เราใช้ variant + ไฟล์ edit.html+turbo_frame.erb แทน (repo อาจารย์ day3-c-done ก็ใช้วิธีนี้) | สนใจ — ถ้าทำตามสไลด์ หน้าเต็มจะพัง |
| 41 · 42 | "ใช้ dom_id(post) ทั้งสองฝั่ง" · ตัวอย่าง id post_5 | ในแอปเรา post_30 ถูก <article> ใช้แล้ว → ใช้ dom_id(post, :title) = title_post_30 ทั้งสองฝั่ง | สนใจ — หลักการเดิม แค่เติมคำ |
| 42 | id ไม่ตรง "กล่องจะว่างเปล่า" | กล่องขึ้นคำว่า Content missing | ไม่ต้อง — อาการเดียวกัน |
| 42 | console ขึ้น "Response has no matching <turbo-frame id=...>" | ข้อความจริง: The response (200) did not contain the expected <turbo-frame id="…"> and will be ignored… | สนใจถ้าจะค้นหาข้อความ error |
| 43 | lazy frame ใช้ post_comments_path(post) | ยังไม่มีคอมเมนต์ในแอปจนถึงบท 132 · พิมพ์ตามตอนนี้จะ error | ไม่ต้อง — แค่รู้จักไว้ |
อ้างอิง
- Turbo Handbook — Decompose with Turbo Frames —
อ่านตัวอย่างแรก (กล่อง message แก้ข้อความในที่) ภาพเดียวกับที่เราทำ ·
ข้ามไปหัวข้อ Targeting Navigation Into or Out of a Frame เรื่อง
_top· และหัวข้อ "Breaking out" from a Frame ยืนยันว่ากล่องไม่ตรง = เขียนข้อความลงกล่องแล้ว throw exception - Turbo Reference — Frames — ดูแค่ Basic frame · Lazy-loaded frame · Frame targeting the whole page by default เป็นตัวอย่าง HTML สั้น ๆ ของแต่ละแบบ
- turbo-rails — frame_request.rb —
ไฟล์สั้นมาก · ดูบรรทัด
layout -> { "turbo_rails/frame" if turbo_frame_request? }ที่มาของ "ถาดบาง" และ methodturbo_frame_request? - turbo-rails — frames_helper.rb —
ที่มาของ
turbo_frame_tag· อ่านแค่คอมเมนต์ตัวอย่างด้านบน method (มีตัวอย่างใส่src·target: "_top") - Rails API — RecordIdentifier#dom_id —
ยืนยันว่าคำเติมไปอยู่ข้างหน้า (
dom_id(Post.find(45), :edit)→edit_post_45) - Rails Guides — Layouts and Rendering § The :variants Option —
เรื่อง variant และชื่อไฟล์
+รุ่น· อ่านแค่ย่อหน้านี้ (ตัวอย่างในคู่มือเป็น+mobile) - MDN — Progressive Enhancement — นิยามสั้น ๆ หน้าเดียว