Now.js Framework Documentation
TableManager
TableManager
ภาพรวม
TableManager คือระบบจัดการ data tables ใน Now.js Framework รองรับ sorting, filtering, pagination และ API integration
ใช้เมื่อ:
- ต้องการ data tables
- ต้องการ sorting และ filtering
- ต้องการ pagination
- ต้องการ CRUD operations
- ต้องการ row selection
ทำไมต้องใช้:
- ✅ Auto-load from API
- ✅ Sorting (client/server)
- ✅ Filtering
- ✅ Pagination
- ✅ Row selection (checkbox)
- ✅ Inline editing
- ✅ URL state persistence
- ✅ Sortable rows (drag & drop)
- ✅ UX ลากแถว: ghost เป็นไอคอนติดเมาส์, placeholder เส้นประ, ล็อกการลากแนวตั้ง
การใช้งานพื้นฐาน
HTML Declarative
<table data-component="table"
data-source="/api/users"
data-page-size="10">
<thead>
<tr>
<th data-field="id">ID</th>
<th data-field="name" data-sortable>Name</th>
<th data-field="email" data-sortable>Email</th>
<th data-field="created_at" data-sortable>Created</th>
</tr>
</thead>
<tbody>
<!-- Data will be rendered here -->
</tbody>
</table>With Filters (ตัวกรองบนคอลัมน์)
ใส่ data-filter="true" ที่ <th> แล้ว TableManager จะสร้างตัวกรองไว้เหนือตารางให้เอง
ไม่ต้องสร้างฟอร์มกรอง data-type กำหนดชนิดของตัวกรอง และค่าจะถูกส่งไปที่ data-source
ในชื่อ data-field ของคอลัมน์ (ช่วงวันที่ส่ง 2 พารามิเตอร์) ตัวเลือกจำนวนแถวต่อหน้าและช่องค้นหา
ก็สร้างให้ด้วยจาก data-page-size และ data-search-columns (data-search-placeholder กำหนดข้อความในช่องค้นหา)
พารามิเตอร์ที่ไม่มีคอลัมน์ของตัวเอง (ปี · ตัวเลือก "แสดง" · ยอดที่กรอกเอง) ใช้คอลัมน์ที่มี data-hidden="true"
ได้ตัวกรองแต่ไม่แสดงเซลล์
<table data-table="usage" data-source="api/index/usage" data-page-size="25"
data-search-columns="name,topic,reason">
<thead>
<tr>
<th data-field="created_at" data-format="datetime"
data-filter="true" data-type="daterange" data-range-names="from,to"
data-range-presets="today,7d,30d,thismonth,lastmonth" data-label="Date">Created</th>
<th data-field="src" data-filter="true" data-type="select"
data-show-all="true" data-all-value="" data-label="Module">Module</th>
<th data-field="due" data-filter="true" data-type="date" data-label="Due">Due</th>
</tr>
</thead>
<tbody></tbody>
</table>data-type |
ตัวกรอง | ค่าที่ส่งไป API |
|---|---|---|
select |
ดรอปดาวน์ ตัวเลือกมาจาก data-options, filters ใน response หรือค่าที่พบในข้อมูล |
field=value |
text |
ช่องข้อความ (ตารางฝั่ง client เทียบแบบ "มีคำนี้อยู่") | field=value |
date |
ตัวเลือกวันที่ (ตารางฝั่ง client เทียบทั้งวัน ค่าวันเวลาตรงกับวันของมัน) | field=YYYY-MM-DD |
daterange |
ตัวเลือกช่วงวันที่ตัวเดียว เว้นว่างฝั่งใดฝั่งหนึ่งได้ | from=YYYY-MM-DD&to=YYYY-MM-DD |
number · currency |
ช่องตัวเลข ส่งเมื่อยืนยันค่า (กด Enter หรือออกจากช่อง) · currency รับค่าติดลบและทศนิยม 2 ตำแหน่ง |
field=1250.75 |
tabs |
แถวของแท็บเหนือตัวกรองอื่น หนึ่งแท็บต่อหนึ่งตัวเลือก กดแล้วกรองทันที | field=value |
Attribute ที่ <th> |
ความหมาย |
|---|---|
data-filter="true" |
สร้างตัวกรองของคอลัมน์นี้ |
data-type |
ชนิดของตัวกรอง (ตารางด้านบน) |
data-label |
ป้ายชื่อของตัวกรอง เป็นคีย์แปล (Date หรือ {LNG_Review from} ({LNG_Leave Request})) — ไม่ใส่ = คีย์ที่ข้อความหัวคอลัมน์มาจาก (data-i18n) ป้ายจึงเปลี่ยนตามภาษาเมื่อสลับภาษา |
data-unit |
หน่วยท้ายช่องของตัวกรอง (days · %) เป็นคีย์แปล แสดงเป็น <span class="unit"> ในกรอบเดียวกับช่อง · คอลัมน์จาก API ส่ง unit ใน columns |
data-placeholder |
ข้อความ placeholder |
data-options |
ตัวเลือกของ select แบบ JSON |
data-show-all · data-all-value · data-all-label |
ตัวเลือก "ทั้งหมด" ของ select |
data-range-names="from,to" |
ชื่อพารามิเตอร์ 2 ตัวของ daterange (ค่าเริ่มต้น {field}_from, {field}_to) |
data-range-presets |
ปุ่มลัด: today yesterday 7d 30d thismonth lastmonth thisyear |
data-range-max-days |
ความยาวช่วงที่ยอมให้มากที่สุด (วัน) |
data-min · data-max |
วันแรก / วันสุดท้ายที่เลือกได้ (YYYY-MM-DD) ของตัวกรองวันที่ หรือค่าต่ำสุด / สูงสุดของตัวกรอง number |
data-step · data-precision |
ทศนิยมของตัวกรอง number (data-step="0.01" = 2 ตำแหน่ง) อ่านแบบเดียวกับช่องแก้ไขในเซลล์ |
data-hidden="true" |
ตัวกรองอย่างเดียว คอลัมน์ไม่แสดงเซลล์ |
Attribute ที่ <table> |
ความหมาย |
|---|---|
data-search-placeholder |
ข้อความในช่องค้นหา เป็นคีย์แปล ({LNG_Name or email}) ค่าเริ่มต้น: "Search in: " ตามด้วยหัวคอลัมน์ของช่องที่ค้นซึ่งตารางแสดง (ช่องที่ไม่มีคอลัมน์ไม่ถูกแสดง · ไม่มีเลย = "Search...") |
ตัวเลือกของ select และ tabs มาจาก filters ใน response ตามชื่อ field:
{"filters": {"status": [{"value": "", "text": "ทุกสถานะ"}, ...]}}
endpoint ที่ส่งตัวเลือก "ทั้งหมด" มาเองให้ใส่ data-show-all="false" ที่คอลัมน์ จะได้ไม่มีตัวที่สองซ้ำ
ตัวกรองที่ผู้ดูใช้ไม่ได้ให้ส่ง false แทนตัวเลือก ({"filters": {"member_id": false}}) ตัวกรองนั้นถูกซ่อน และกลับมาเมื่อ response ถัดไปส่งตัวเลือกมา
ตัวเลือกชุดเดียวกันใช้ได้สองแบบตาม data-type ของคอลัมน์:
data-type="select"แสดงเป็นรายการให้เลือก (มี "ทั้งหมด" ตามปกติ)data-type="text"เป็นช่องพิมพ์ที่เลือกจากรายการได้ (เหมือน input + datalist · autocomplete ของ TextElementFactory) —
เหมาะกับรายการยาว เช่นรายชื่อพนักงาน: พิมพ์บางส่วนแล้วเลือก ช่องแสดงtextตัวกรองส่งvalue(member_id=8ไม่ใช่ชื่อ)- พิมพ์เฉย ๆ ยังไม่กรอง · กรองเมื่อเลือกจากรายการ หรือกด Enter/ออกจากช่องโดยที่ข้อความตรงกับตัวเลือก · ล้างช่อง = ไม่กรอง
- ตัวเลือกค่าว่าง ("ทั้งหมด") ไม่ถูกเสนอ · ค่าจาก URL/meta ที่ตรงกับตัวเลือกแสดงเป็นข้อความ ค่าที่ไม่ตรง (เช่น 0) ไม่ถูกเขียนลงช่อง
- ตัวกรอง text ที่ไม่มีตัวเลือกกรองตามข้อความที่พิมพ์เหมือนเดิม
<th data-field="member_id" data-filter="true" data-type="text" data-template="${name}">Name</th>
กลุ่มคอลัมน์จากข้อมูล (data-column-group)
ตารางที่จำนวนคอลัมน์ขึ้นกับข้อมูล (คอลัมน์ต่อประเภทการลา · ต่อวัน) เขียนคอลัมน์ต้นแบบไว้ในแม่แบบครั้งเดียว
API ส่งแค่รายการเป็นข้อมูลใน columnGroups — HTML ทั้งหมดอยู่ในแม่แบบ (ไม่ใช้ data-dynamic-columns ที่ API ต้องส่งหัวตารางมา)
<thead>
<tr>
<th data-field="name" rowspan="2">Name</th>
<!-- หัวกลุ่ม: ซ้ำหนึ่งช่องต่อรายการ คร่อมคอลัมน์ย่อยทั้งหมด -->
<th data-column-group="types" class="center"><span class="one_line" title="{topic}">{topic}</span></th>
</tr>
<tr>
<!-- คอลัมน์ย่อยต้นแบบของกลุ่ม -->
<th data-group-of="types" data-field="limit_{id}" data-i18n>Limit</th>
<th data-group-of="types" data-field="taken_{id}" data-sort="taken_{id}"
data-template="<a href='${taken_url_{id}}'>${taken_{id}}</a>" data-i18n>Taken</th>
</tr>
</thead>{"columnGroups": {"types": [{"id": 1, "topic": "ลาป่วย"}, {"id": 2, "topic": "ลากิจ"}]},
"data": [{"name": "...", "limit_1": "30 วัน", "taken_1": "2 วัน", "taken_url_1": "/leave-approvals?..."}]}{key}ในทุกแอตทริบิวต์และข้อความของต้นแบบถูกแทนด้วยค่าของรายการ (ข้อความถูก escape) ·{LNG_...}ไม่ถูกแตะ- หัวกลุ่มที่ไม่มี
data-group-ofคือคอลัมน์เอง (กลุ่มชั้นเดียว เช่นคอลัมน์ต่อวัน):<th data-column-group="days" data-field="d_{key}">{label}</th> - หลายช่องในแถวหัวเดียวใช้ชื่อกลุ่มเดียวกันได้: ทุกช่องซ้ำต่อรายการในตำแหน่งของตัวเอง จึงใช้สลับชุดคอลัมน์ตามข้อมูลได้
— รายการหนึ่งตัว = แสดงทั้งชุด · รายการว่าง = ซ่อนทั้งชุด (เช่นคอลัมน์ของเอกสารที่เลือก:pnd1: [{key: 'pnd1'}], sso: [])<th data-column-group="pnd1" data-field="date" data-format="date">Date paid</th> <th data-column-group="pnd1" data-field="tax" class="right">Tax withheld</th> <th data-column-group="sso" data-field="code">Name title code</th> <th data-column-group="sso" data-field="wage" class="right">Wages</th> - รายการเดิมไม่สร้างหัวตารางใหม่ · รายการเปลี่ยน (เปลี่ยนปี → ประเภทเปลี่ยน) สร้างใหม่ · คอลัมน์ที่สร้างเรียงลำดับ (data-sort) และเป็นช่องกรอก (data-cell-element) ได้เหมือนคอลัมน์ปกติ
- Gcms\Table: override
getColumnGroups($params, $login)คืน['types' => [...]]
ค่าที่ server เลือกให้ (เดือนปัจจุบัน · บัญชีแรก) ส่งกลับในmetaแล้วตัวกรองแสดงค่านั้น
ตัวกรองแบบแท็บ (data-type="tabs")
- ตัวเลือกมีจำนวนได้
{"value": "verify", "text": "รอตรวจสลิป", "count": 3}จำนวนที่มากกว่าศูนย์แสดงเป็นป้ายข้างข้อความ
จำนวนมากับทุก response จึงตรงกับรายการเสมอ - แท็บเป็นแถวแรกของแถบตัวกรอง แท็บที่ทำงานคือค่าปัจจุบัน (จาก URL สถานะที่จำไว้ หรือ
meta) ลิงก์ที่มี?field=valueเปิดที่แท็บนั้น
ตารางที่อยู่ในฟอร์ม (ช่องในเซลล์ที่ฟอร์มบันทึก): แถบตัวกรองอยู่ในฟอร์มด้วย ค่าของตัวกรองจึงไปกับฟอร์มตามชื่อของมัน
(ตัวกรอง year ให้ปีแก่การบันทึก) กด Enter ในช่องของตัวกรองเป็นการกรอง ไม่ส่งฟอร์ม
ตัวกรองช่วงวันที่ (data-type="daterange")
- ปฏิทินมีแถบ จาก / ถึง วันแรกที่เลือกคือวันเริ่ม แล้วจะไปที่ฝั่ง ถึง ต่อ วันที่สองปิดปฏิทิน
ถ้าเลือกวันก่อนวันเริ่ม วันนั้นกลายเป็นวันเริ่มและวันเริ่มเดิมกลายเป็นวันสิ้นสุด - เลือกฝั่งเดียว: × ล้างทีละฝั่ง เลือกวันเริ่มแล้วปิดปฏิทิน (Esc หรือคลิกข้างนอก) = มีแค่ "จาก"
กดแถบ ถึง แล้วเลือกวัน = มีแค่ "ถึง" ฝั่งที่ว่างส่งเป็นค่าว่าง (from=&to=2026-10-16) - ตารางโหลดใหม่ครั้งเดียวต่อหนึ่งช่วง ตอนปิดปฏิทิน ไม่ใช่ทุกครั้งที่คลิก ปุ่ม Backspace ล้างทั้งสองฝั่ง
- พารามิเตอร์ทั้งสองถูกเขียนลง URL (เป็น
tableId.fromเมื่อหน้ามีหลายตาราง) และสถานะที่จำไว้
เปิดลิงก์ที่มีค่าแล้ว picker แสดงช่วงเดิม - ฝั่ง server อ่านเหมือนพารามิเตอร์อื่น:
$request->get('from')->date(),$request->get('to')->date()
วันสิ้นสุดนับเต็มวัน คอลัมน์ชนิดวันเวลาจึงต้องเทียบกับto.' 23:59:59'
ฟอร์มกรองภายนอก (data-table-filter)
ใช้ตัวกรองบนคอลัมน์ (ด้านบน) ทุกครั้งที่ทำได้ — ครอบคลุม select, แท็บ, วันที่, ช่วงวันที่, ตัวเลข, ข้อความ และค้นหา
ฟอร์มแยกมีไว้สำหรับหน้าตาที่ตัวกรองบนคอลัมน์สร้างไม่ได้
คุณสามารถสร้างฟอร์มกรองแยกจากตาราง แล้วเชื่อมด้วย data-table-filter="tableId" เพื่อออกแบบ UI ได้อิสระ (รองรับตัวกรองช่วง เช่น วันที่/ราคา) โดยตารางยังจัดการโหลด/ซิงก์สถานะ, URL params และ options จาก API ให้อัตโนมัติ
<!-- External Filter Form -->
<form data-table-filter="usage" class="table_nav">
<div>
<label>Show</label>
<select name="pageSize">
<option value="10">10 entries</option>
<option value="25">25 entries</option>
<option value="50">50 entries</option>
</select>
</div>
<!-- ตัวกรองช่วงวันที่ -->
<input type="date" name="from">
<input type="date" name="to">
<!-- ค้นหา -->
<input type="search" name="search" placeholder="Search...">
<button type="submit">Apply</button>
</form>
<!-- Table -->
<table data-table="usage"
data-source="api/index/usage"
data-default-sort="created_at desc"
data-page-size="25"
data-search-columns="name,topic,reason">
<thead>
<tr>
<th data-field="id" data-sort="id">ID</th>
<th data-field="created_at" data-sort="created_at" data-format="date">Created</th>
<th data-field="topic" data-sort="topic">Topic</th>
<th data-field="name" data-sort="name">Name</th>
</tr>
</thead>
<tbody></tbody>
</table>หลักการทำงาน
data-table-filter="tableId"ต้องตรงกับdata-tableของตาราง- กด submit ฟอร์มจะรีโหลดข้อมูลตาราง (ไม่มี page reload)
- ตัวกรองช่วงใช้คู่ชื่อ เช่น
from/to,price_min/price_maxและส่งค่า non-empty ทั้งหมดไปยัง API - URL ซิงก์อัตโนมัติและกู้ค่าฟอร์มเมื่อโหลดหน้า
- ถ้า API ส่ง
optionsกลับมา (เช่น{ status: [{value:'active', text:'Active'}] }) จะเติมลง select ที่nameตรงกันอัตโนมัติ
ตัวอย่างคำขอ API
GET /api/index/usage?page=1&pageSize=25&from=2024-01-01&to=2024-12-31&search=testหมายเหตุ
- ฟิลเตอร์ใน
<th>ยังใช้งานได้; เมื่อมี external form ระบบยังอ่าน metadata จาก<th>(ค่า default / options) แต่ใช้ UI จากฟอร์มภายนอกแทน - ตั้งชื่อฟิลด์ให้ตรงกับพารามิเตอร์ที่ API รองรับ (เช่น
from,to,search)
Data Attributes
| Attribute | Description |
|---|---|
data-component="table" |
Initialize table |
data-table |
Table identifier (required for all tables) |
data-source |
API endpoint |
data-cache |
เปิด cache สำหรับคำขอ GET จาก data-source |
data-cache-time |
อายุ cache ของ data-source หน่วยมิลลิวินาที |
data-load-target |
CSS selector ของ container ที่จะถูก bind ใหม่จาก payload ของตารางหลังโหลดข้อมูลสำเร็จ · หลายจุดคั่นด้วยจุลภาค (#report-top, #report-body) ได้ payload เดียวกันทุกจุด |
data-page-size |
จำนวนรายการต่อหน้า |
data-show-footer |
เปิดการประมวลผล footer |
data-footer-aggregates |
mapping แบบ legacy สำหรับสร้าง aggregate อัตโนมัติใน tfoot ที่ว่าง |
เมื่อมีการแก้ไขข้อมูลผ่าน TableManager แล้วต้อง reload แหล่งข้อมูลระยะไกล การ reload รอบนั้นจะข้าม cache อัตโนมัติ เพื่อให้ข้อมูลหลัง action สดเสมอ
| data-page-sizes | ตัวเลือกจำนวนรายการต่อหน้า |
| data-default-sort | การเรียงลำดับเริ่มต้น (เช่น "name asc" หรือ "created_at desc,name asc") |
| data-show-checkbox | แสดง checkbox เลือกแถว |
| data-show-caption | แสดง/ซ่อน caption (ค่าเริ่มต้น: true, แต่ถูกปิดอัตโนมัติเมื่อใช้ data-editable-rows="true") |
| data-editable-rows | เปิดใช้การแก้ไขแถว (เพิ่ม/ลบ) |
| data-row-sortable | เปิดการลากเรียงลำดับแถว (ทำงานอิสระจาก data-editable-rows) |
| data-sortable-rows | เปิดใช้ลากวางเรียงลำดับ |
| data-url-params | บันทึกสถานะใน URL (หน้าเดียวมีหลายตาราง แต่ละตารางใช้ tableId.key) |
| data-remember-state | จำขนาดหน้าและการเรียงของแต่ละตาราง และตัวกรองที่ใส่ data-remember="true" ไว้ใช้ตอนเข้าใหม่จากเมนูหรือ URL (ค่าเริ่มต้น true · การย้ายไปมาภายในแอปคงทุกตัวเลือกไว้เสมอ ดู จดจำสถานะของตาราง) |
| data-persist-column-widths | ปรับความกว้างคอลัมน์ได้และจำไว้ (ค่าเริ่มต้น true ดู ปรับความกว้างคอลัมน์) |
| data-refresh-interval | โหลดข้อมูลใหม่จาก data-source ทุกกี่วินาที (0 = ปิด ซึ่งเป็นค่าเริ่มต้น) |
โหลดข้อมูลใหม่อัตโนมัติ
ใส่ data-refresh-interval ให้ตาราง แล้วมันจะโหลดตัวเองใหม่จาก data-source ตามเวลาที่ตั้ง
โดยยังคงหน้าปัจจุบัน การเรียงลำดับ และตัวกรองไว้เหมือนเดิม
<table data-table="logLines" data-source="/api/v2/logs" data-refresh-interval="5">ค่าเริ่มต้นคือ 0 ตารางที่ไม่ได้ใส่แอตทริบิวต์นี้จึงไม่ยิงคำขอเพิ่มเลย และทำงานเหมือนเดิมทุกประการ
สามพฤติกรรมที่ได้มาให้เลย เพราะหน้าจอที่ poll โดยไม่คิดถึงสามข้อนี้จะกลายเป็นปัญหาภายในวันเดียว
| สถานการณ์ | สิ่งที่เกิดขึ้น | เหตุผล |
|---|---|---|
| โหลดช้ากว่ารอบที่ตั้งไว้ | ตั้งรอบถัดไปหลังรอบก่อนหน้าจบแล้วเท่านั้น | ใช้ setTimeout ต่อกันเป็นทอด ไม่ใช่ setInterval — คำขอจึงซ้อนกันไม่ได้เลย |
| แท็บไม่ได้ถูกมองอยู่ | หยุด poll · กลับมาแล้วโหลดทันทีหนึ่งรอบถ้าข้ามรอบไป | หน้าที่เปิดค้างข้ามคืนจะยิงคำขอนับพันครั้งโดยไม่มีใครเห็นสักครั้ง |
| มีแถวถูกเลือกอยู่ | ข้ามรอบนั้นไป | การโหลดใหม่ล้างการเลือก · รีเฟรชกลางคันจึงเท่ากับทิ้งสิ่งที่ผู้ใช้เพิ่งเลือกไว้ |
การโหลดที่ล้มเหลวจะไม่หยุดตัวจับเวลา — ปลายทางอาจแค่ล่มชั่วคราว ถ้าเลิกตรงนั้นผู้ใช้จะเหลือ
หน้าจอที่ค้างอยู่โดยไม่มีอะไรบอกว่าการรีเฟรชหยุดไปแล้ว
เปลี่ยนความถี่ระหว่างใช้งานด้วย setRefreshInterval()
ซึ่งเป็นตัวที่ dropdown "รีเฟรชทุก…" ผูกไว้ · ทุกครั้งที่โหลดอัตโนมัติจะยิงเหตุการณ์
table:refreshed ให้หน้าจอเอาไปแสดงเวลาที่ข้อมูลมาถึงล่าสุดได้
จดจำสถานะของตาราง
ทุกตารางจำสิ่งที่ผู้ใช้เลือกไว้เองโดยไม่ต้องเขียนโค้ด
ย้ายไปมาภายในแอป ทุกตัวเลือกกลับมาครบ — ตารางกลับมาเหมือนเดิมทุกอย่าง:
ตัวกรองทุกตัวไม่ว่าจะใส่ data-remember หรือไม่ คำค้น เลขหน้า ขนาดหน้า และการเรียง —
แม้ใส่ data-remember-state="false" ก็ตาม เมื่อ:
- บันทึกฟอร์มแล้วกลับมา (redirect
backหรือ redirect ไปที่ path ของรายการ) กด row action หรือลิงก์ธรรมดาในหน้า - กดย้อนกลับหรือไปข้างหน้าของเบราว์เซอร์
- รีโหลดหน้า (F5 หรือ
location.reload()หลังบันทึก) - ตารางถูกสร้างใหม่บนหน้าเดิม (component หรือ template render ใหม่)
เข้าใหม่จากเมนูหรือ URL ใช้การตั้งค่าการจำ — กติกาด้านล่างมีผลเฉพาะเมื่อ:
- กดลิงก์ในเมนู (อยู่ใน
data-component="menu",.sidemenuหรือ.topmenu) - เปิด URL ในเบราว์เซอร์ (พิมพ์เอง ลิงก์จากภายนอก แท็บใหม่)
- ลิงก์หรือ redirect ภายในแอปที่ URL ระบุพารามิเตอร์ของตาราง (
/orders?status=pending) — แสดงตามที่ระบุ ไม่นำคำค้นเก่ามาปน
| สิ่งที่จำ | เก็บที่ | เหตุผล |
|---|---|---|
| ขนาดหน้า การเรียง | localStorage |
ผู้ใช้ตั้งใจเลือก ควรคงอยู่ในครั้งต่อไป |
ตัวกรองที่คอลัมน์ใส่ data-remember="true" |
localStorage |
เฉพาะหน้าที่จำเป็น (ปีงบที่ทำงานอยู่ทั้งวัน) |
ค่าเริ่มต้นไม่จำตัวกรอง — คน สถานะ หรือวันที่ที่เลือกดูครั้งหนึ่งไม่ควรค้างอยู่ในการเข้าครั้งถัดไป
ตัวกรองจึงเริ่มจากค่าเริ่มต้น (หรือค่าใน meta) เว้นแต่คอลัมน์ขอให้จำ:
<th data-field="year" data-filter="true" data-type="select" data-remember="true" data-hidden="true">ปีงบประมาณ</th>ตัวกรองช่วงวันที่ใส่ที่คอลัมน์ของตัวเอง (จำทั้งสองพารามิเตอร์) · ค่าที่จำไว้ก่อนคอลัมน์เลิกจำจะไม่ถูกใช้
ไม่จำคำค้นและเลขหน้าไปถึงการเข้าใหม่ — เข้าใหม่เมื่อไรช่องค้นหาจะว่างและเริ่มหน้าแรก
ส่วนตัวกรองที่ใส่ data-remember="true" ยังจำตามปกติ · คำค้นไม่อยู่ใน URL และเลขหน้าระหว่างค้นหาก็ไม่อยู่เช่นกัน เพราะเป็นหน้าของผลการค้นหา ·
การย้ายไปมาภายในแอปยังคงทั้งสองอย่างไว้ · ลิงก์ที่มี ?search= หรือ ?page= มาด้วยยังเปิดตามนั้นได้
กติกา:
- URL ที่มีพารามิเตอร์ของตารางชนะ เมื่อเข้าใหม่ (
?page=3,?status=1ลิงก์ที่แชร์)
และแสดงตามนั้นทุกอย่าง · สถานะที่จำไว้ใช้เฉพาะเมื่อ URL ไม่มีพารามิเตอร์ของตารางเลย
แล้ว URL จะถูกอัปเดตให้ตรงกับที่แสดง - แยกการเข้าใหม่ออกจากการย้ายไปมาภายในแอปด้วยเบราว์เซอร์ (ประเภทการเปิดเอกสาร) และด้วย
sourceของ
route:before(เมนูส่งmenu) ·
สถานะล่าสุดของแต่ละตารางเก็บในsessionStorageที่คีย์now.table.live:ตลอดอายุของแท็บ - ตอนกดย้อนกลับ ค่าที่อยู่ใน URL ของรายการประวัตินั้นชนะ ส่วนที่ URL ไม่มี (คำค้น) มาจากสถานะล่าสุดของตาราง ·
การเข้าใหม่ทับสถานะล่าสุดนี้ ย้อนกลับไปหน้ารายการก่อนกดเมนูจึงได้ตัวกรองจาก URL แต่ไม่ได้คำค้นเดิม - จำแยกตาม route, id ของตาราง และพารามิเตอร์อื่นใน URL —
?module_id=5กับ
?module_id=6จึงเป็นคนละรายการ - คืนค่าเฉพาะที่ยังใช้ได้: ขนาดหน้าที่ตัวเลือกยังมี ตัวกรองที่ยังอยู่และยังใส่
data-remember="true"คอลัมน์ที่ยังเรียงได้ ·
เลขหน้าที่เกินหน้าสุดท้ายจะแสดงหน้าสุดท้าย (ตาราง server แก้จากmetaตาราง client แก้เอง) - ตาราง
data-dynamic-columnsยังไม่มีหัวตารางจนกว่าข้อมูลชุดแรกจะมาถึง การเรียงที่จำไว้จึงถูกส่งไปตามเดิม
(คอลัมน์ที่ server อนุญาตให้เรียงเป็นตัวกั้น) แล้วตรวจอีกครั้งเมื่อหัวตารางมาถึง — คอลัมน์ที่ API
ไม่ส่งมาแล้วจะถูกตัดออกจากการเรียง จาก URL และจากสถานะที่จำไว้ - ปิดรายตารางด้วย
data-remember-state="false"(การย้ายไปมาภายในแอปยังคงทุกตัวเลือกไว้) · ล้างด้วย
clearTableCache(tableId)· การนำตารางออกจากหน้าไม่ได้ล้างอะไร
ถ้า หน้าเดียวมีหลายตาราง พารามิเตอร์ใน URL จะมี id ของตารางนำหน้า
(?orders.page=2&items.sort=name+asc) เพื่อไม่ให้ทับกัน · หน้าที่มีตารางเดียวใช้ชื่อเดิม
ปรับความกว้างคอลัมน์
ทุกตารางปรับความกว้างคอลัมน์ได้โดยไม่ต้องเขียนโค้ด:
- ลากขอบขวาของหัวคอลัมน์ — เมาส์ นิ้ว หรือปากกา
- กด Tab ไปที่ขอบ แล้วกด ← / → (ครั้งละ 10px กด Shift = 50px)
- ดับเบิลคลิกที่ขอบเพื่อคืนทุกคอลัมน์เป็นความกว้างอัตโนมัติ
ความกว้างเก็บตาม field ของคอลัมน์ (ไม่ใช่ลำดับ) แยกตาม route + id ของตาราง
ซ่อนหรือย้ายคอลัมน์ความกว้างจึงไม่ไปตกกับคอลัมน์ข้าง ๆ · ค่าที่รุ่นก่อนเก็บไว้
(table_<id>_columns) ถูกแปลงให้ครั้งเดียว · ปิดด้วย data-persist-column-widths="false"
Declarative Load Target Binding
ใช้ data-load-target เมื่อต้องการให้ response ของตารางไป hydrate container อื่นต่อโดยไม่ต้องเขียน JavaScript เฉพาะหน้าเอง หลังจาก TableManager normalize payload แล้ว ระบบจะส่ง payload เดียวกันเข้า target ผ่าน TemplateManager.processTemplate() และ processDataOnLoad()
เหมาะกับกรณี header, summary card, ตัวนับผลลัพธ์, empty state หรือ side panel ที่ควรเปลี่ยนตาม response ชุดเดียวกับตาราง
state ที่ target ได้รับ
target จะได้รับ normalized payload บน context.state เช่น:
{
data: [...],
meta: {
page: 1,
pageSize: 25,
total: 120,
totalPages: 5
},
filters: {},
options: {},
columns: [],
params: {type: 'customer'},
tableId: 'customers',
tableSource: 'api/customer/customers',
isServerSide: true,
hasData: true,
empty: false,
raw: {/* payload ต้นฉบับ */}
}array หลักของแถวข้อมูลจะถูกเปิดให้ใช้บน context.data ด้วย
ตัวอย่าง: ให้ header เปลี่ยนตาม payload ของตาราง
<header id="customer-table-header">
<div>
<h1 data-text="params.type === 'partner' ? '{LNG_Partner}' : '{LNG_Customer}'">Customer</h1>
<p data-text="params.type === 'partner' ? 'Manage partner information' : 'Manage customer information'">
Manage customer information
</p>
</div>
<a class="btn btn-primary icon-new"
href="/customer"
data-attr="href:'/customer?type=' + (params.type || 'customer')"
data-text="params.type === 'partner' ? '{LNG_Add} {LNG_Partner}' : '{LNG_Add} {LNG_Customer}'">
{LNG_Add} {LNG_Customer}
</a>
</header>
<table data-table="customers"
data-source="api/customer/customers"
data-load-target="#customer-table-header"
data-page-size="25">
<thead>...</thead>
<tbody></tbody>
</table>หมายเหตุ
data-load-targetทำงานหลัง normalize ข้อมูลแล้ว ดังนั้น target ใช้data,meta,filters,optionsและค่าปัจจุบันใน table params ได้ทันที- ฟอร์มข้างตารางเติมตัวเลือกจากคำตอบเดียวกันได้:
<select data-options-key="shift_id">ใน target อ่านoptions.shift_id· ตารางผูกข้อมูลใน target (data-attr="data:options.departments") ใช้ได้เหมือนใน data-component - target ใช้ directive ปกติของ TemplateManager ได้ เช่น
data-text,data-attr,data-class,data-if,data-forและdata-on-load - target จะถูก bind ใหม่ทุกครั้งที่ตาราง reload จาก API หรือ state ผ่าน TableManager
- ถ้า API รู้ label หรือ summary ที่ถูกต้องอยู่แล้ว ควรส่งมาใช้ตรงๆ จาก server; ใช้
params.*สำหรับค่าที่มาจาก state ของตารางหรือ URL params เป็นหลัก
ซ่อน/แสดงคอลัมน์
คุณสามารถซ่อนคอลัมน์ได้ 2 วิธี:
1. ใช้ data-visible="false" หรือ data-hidden="true" ใน HTML
<table data-table="users" data-source="/api/users">
<thead>
<tr>
<th data-field="id" data-visible="false">ID</th>
<th data-field="internal_code" data-hidden="true">Internal Code</th>
<th data-field="name">Name</th>
<th data-field="email">Email</th>
</tr>
</thead>
</table>data-hidden="true" เป็น alias ของ data-visible="false" ใช้แบบไหนใน HTML declarative ก็ได้ และทั้งคู่จะซ่อนทั้ง header กับ body cells ของคอลัมน์นั้น รวมถึงช่องของคอลัมน์นั้นในแถวท้ายที่ตารางสร้างเอง (data-show-footer) ยอดรวมจึงอยู่ใต้หัวคอลัมน์ของตัวเองเสมอ · toggleColumnVisibility() ซ่อน/แสดงช่องท้ายตารางไปพร้อมกับคอลัมน์
2. ใช้ JavaScript แบบ dynamic
// ซ่อนคอลัมน์
TableManager.toggleColumnVisibility('users', 'email', false);
// แสดงคอลัมน์
TableManager.toggleColumnVisibility('users', 'email', true);หมายเหตุ: คอลัมน์ที่ซ่อนจะไม่แสดงทั้ง <th> และ <td> แต่ยังคงข้อมูลไว้ใน memory
Built-in Cell Formatting
ใช้ data-format บน <th> ของคอลัมน์ เมื่อต้องการให้ TableManager จัดรูปแบบข้อความที่แสดงผลโดยไม่ต้องเขียน custom formatter เอง
| Attribute | ใช้กับ | คำอธิบาย |
|---|---|---|
data-format |
HTML headers และ dynamic columns | รูปแบบ built-in เช่น text, number, currency, percent, lookup, date, time, datetime, bytes, duration, boolean, humanize, truncate |
data-decimals |
number, currency, percent |
จำนวนตำแหน่งทศนิยมแบบ fix |
data-locale |
number, currency, percent |
locale ที่ส่งต่อเข้า Intl.NumberFormat |
data-currency |
currency |
currency code/label ที่ส่งเข้า shared currency formatter |
data-pattern |
date, time, datetime |
รูปแบบการแสดงวันที่/เวลา ที่ส่งให้ Utils.date.format() |
data-options |
lookup |
fallback options สำหรับกรณีที่ไม่มี options จาก API/filter |
คอลัมน์แบบ currency ตอนนี้ใช้ formatter contract เดียวกับ template bindings แล้ว ดังนั้นพฤติกรรมเรื่องทศนิยมจะสอดคล้องกันระหว่าง data-text="amount | currency:THB:th-TH:4" และคอลัมน์ของตาราง
อาร์กิวเมนต์หลัง : (เขียนแบบเดียวกับ data-text)
data-format ใส่อาร์กิวเมนต์ต่อท้ายชื่อรูปแบบได้ เขียนเหมือน data-text / data-format ของ template ทุกประการ — คัดลอกจากที่หนึ่งไปอีกที่ได้เลย
| เขียนว่า | เท่ากับ |
|---|---|
number:2 |
data-format="number" data-decimals="2" |
percent:1 |
data-format="percent" data-decimals="1" |
currency:THB:th-TH:2 |
data-currency="THB" data-locale="th-TH" data-decimals="2" |
date:D MMM YYYY · datetime:D/M/YYYY HH:mm · time:HH:mm |
data-pattern="..." (รูปแบบมี : ในตัวได้ ส่วนที่เหลือทั้งหมดคือรูปแบบเดียว) |
truncate:30 |
ตัดที่ 30 ตัวอักษร (นับ ... รวมอยู่ด้วย) |
attribute ที่ระบุตรง ๆ มีผลเหนืออาร์กิวเมนต์ — data-format="number:2" data-decimals="0" ได้ทศนิยม 0 ตำแหน่ง ใช้ได้ทั้งเซลล์ แถวสรุปท้ายตาราง (data-aggregate) และการส่งออก
<th data-field="cost" data-format="number:2" class="right" data-cell-class="right">ราคาทุน</th>
<th data-field="created_at" data-format="datetime:D MMM YYYY HH:mm">วันที่</th>ตัวอย่างแบบ Static HTML
<table data-table="stockMovements" data-source="api/inventory/stockmovements">
<thead>
<tr>
<th data-field="quantity" data-format="currency" data-decimals="4">Quantity</th>
<th data-field="unit_cost" data-format="currency" data-currency="THB" data-locale="th-TH" data-decimals="2">Unit Cost</th>
<th data-field="occurred_at" data-format="date" data-pattern="D MMM YYYY">Date</th>
</tr>
</thead>
</table>ตัวอย่างแบบ Dynamic Columns
{
"columns": [
{
"field": "quantity",
"label": "Quantity",
"format": "currency",
"decimals": 4
},
{
"field": "total_cost",
"label": "Total",
"format": "currency",
"currency": "THB",
"locale": "th-TH",
"decimals": 2
}
],
"data": []
}หมายเหตุ:
lookupยัง resolve options จาก API response, filter options หรือdata-optionsตามลำดับเดิม
label ของ option ที่มี{LNG_...}(เช่น{LNG_Level} 1) ถูกแปลในเซลล์เหมือนที่ filter select แสดง
ส่วน label คำเปล่าและค่าของแถวที่ไม่ตรง option ใดแสดงตามที่เขียน- built-in formats ที่ไม่ใช่
lookupจะอ่าน attributes ของคอลัมน์โดยตรง จึงประกาศdecimals,currency,locale,patternแยกต่อคอลัมน์ได้ - ใช้
formatterเมื่อเซลล์ต้องการ logic ที่อิงทั้งแถว, custom HTML หรือ pipeline การ render ที่กำหนดเอง
เซลล์แบบ data-template กับข้อมูลของแถว
data-template บน <th> เป็น HTML ที่นักพัฒนาเขียน ส่วน ${field} คือค่าจากแถว (ข้อมูลจากเซิร์ฟเวอร์
ที่ผู้ใช้อาจเป็นคนกรอก) สองอย่างนี้ถูกแยกจากกันตั้งแต่ต้น:
- token ของ template (
{LNG_...},{{ expr }},data-*directive) ถูกประมวลผลจาก template เท่านั้น
ค่าจากแถวถูกใส่กลับเข้าไปใน HTML ที่เสร็จแล้ว (escape แล้ว) — ค่าที่เก็บว่า{{7*7}}แสดงเป็น{{7*7}}
และ{LNG_Delete}แสดงตามตัวอักษร ไม่ถูกแปล (เดิมถูกประมวลผลซ้ำ = template injection ผ่านข้อมูลตาราง) - ค่าที่เป็น
{html: '...'}ยังแทรกเป็น HTML ได้ แต่ผ่าน sanitizer เหมือนเดิม {html: '...', i18n: true}— server บอกว่า markup นี้มี{LNG_...}ให้แปลฝั่ง client: TableManager แปล
marker ตอน render ทุกครั้ง (เฉพาะ marker ปีกกาอื่นไม่ถูกแตะ) และเพราะเรนเดอร์จากค่าดิบใหม่เมื่อสลับภาษา
เซลล์จึงตามภาษา UI โดยไม่ต้องยิง API — ต่างจาก markup ที่ PHPLanguage::trans()ไว้ ซึ่งค้างภาษาที่ server
ตอบมา ใช้ได้ทั้งคอลัมน์data-template="${field}"และคอลัมน์ธรรมดา ไม่มี flag = พฤติกรรมเดิม${field|formatter:args}จัดรูปค่าของฟิลด์ด้วย formatter ชุดเดียวกับdata-text(date·datetime·number·currency...) ตามภาษาที่ใช้อยู่
— API ส่งค่าดิบ (2026-09-01) แม่แบบจัดรูปเอง:data-template="${month_date|date:MMMM YYYY}<small>${from|date} – ${to|date}</small>"
ค่าว่าง/null = ว่าง · formatter ที่ไม่รู้จักแสดงค่าดิบ · ใช้ในdata-eachได้${...}ที่ไม่ใช่ชื่อฟิลด์ล้วน (เช่น${id != 1}) ถูกส่งต่อตามตัวอักษร เพื่อใช้กับdata-ifdata-ifในเซลล์ตัดสินต่อแถว ทั้งdata-if="${can_edit}",data-if="${id != 1}"และdata-if="can_edit"
(ค่า null/ว่าง = ซ่อน) — element ที่ไม่ผ่านเงื่อนไขถูกลบออกจากเซลล์ทันที- หลังเรนเดอร์ เซลล์ template และเซลล์ค่าธรรมดาถูกใส่
translate="no"ให้ ข้อมูลในเซลล์จึงไม่ถูก I18nManager
แปลซ้ำ (ตารางเรนเดอร์ใหม่เองเมื่อเปลี่ยนภาษา) — เซลล์ของdata-formatterไม่ถูกใส่ให้ formatter ต้องจัดการเอง
(Now.translate()สำหรับป้าย,cell.setAttribute('translate', 'no')เมื่อใส่ข้อมูลผู้ใช้)
<th data-field="title" data-template="<a href='/doc?id=${id}'>${title}</a>
<button class='btn' data-if='${can_edit}'>{LNG_Edit}</button>"></th>Element ในเซลล์ (ElementManager)
เรนเดอร์ form control ภายในเซลล์ด้วย data-cell-element (หรือใช้ data-type เป็น shorthand) โดย TableManager จะส่ง attributes เข้า ElementManager และเก็บ data-element-id ไว้ที่เซลล์
Attribute ที่รองรับ: placeholder, class, read-only, disabled, required, wrapper, options (select), multiple, allow-empty, rows/cols (textarea), min/max/step/precision (number — precision หรือ decimals กำหนดจำนวนทศนิยมที่พิมพ์ได้ · ถ้าไม่มีทั้งสอง step ที่เป็นทศนิยมให้พิมพ์ทศนิยมได้ตามจำนวนตำแหน่งของมัน เช่น step="0.5" → 1 ตำแหน่ง · min ติดลบทำให้พิมพ์ "-" ได้), datalist, pattern, min-length, max-length (ใช้เมื่อค่ามากกว่า 0 เท่านั้น)
รายการในเซลล์: data-each="tags" วน data-template ตามสมาชิกของอาร์เรย์ในแถว — ${key} อ่านจากสมาชิกก่อนแล้วจึงแถว · รายการค่าธรรมดาใช้ ${item}
(ป้ายหลายป้ายของวัน · ทักษะของคน) <th data-field="badges" data-each="badges" data-template="<span class='status is-${tag}'>${text}</span>"> · data-if ในแม่แบบอ่านฟิลด์ของแถว
ปิดช่องเฉพาะบางแถว: data-cell-disabled="ชื่อฟิลด์" ปิดช่องของแถวที่ฟิลด์นั้นเป็นจริง · "!ชื่อฟิลด์" ปิดเมื่อเป็นเท็จ
— เช่นสถานะงานที่ผู้ดูเปลี่ยนได้เฉพาะงานของทีมตัวเอง: แถวอื่นยังเห็นค่าในช่องที่ปิดไว้
ตารางที่ไม่ใช่แถวแก้ไข (ไม่มี data-editable-rows) ส่งค่าที่เปลี่ยนไป data-action-url เป็น {action: 'update', field, ids: [id ของแถว], value}
<table data-table="tasks" data-source="api/onboarding/checklist" data-action-url="api/onboarding/checklist/action">
<thead>
<tr>
<th data-field="name">งาน</th>
<th data-field="status" data-cell-element="select" data-options-key="task_options" data-cell-disabled="!can_mark">สถานะ</th>
</tr>
</thead>
<tbody></tbody>
</table>ผลรวมท้ายตาราง: data-show-footer="true" data-footer-aggregates='{"weight":"sum"}' (sum · avg · count · min · max) — แก้ค่าในแถวของตารางที่ไม่มี data-action-url แล้วผลรวมคำนวณใหม่ทันที
(เช่นน้ำหนักของหัวข้อที่ต้องรวมได้ 100) · เซลล์ใน <tfoot> ที่มีข้อความอยู่แล้วหรือ colspan มากกว่า 1 คงเดิม
ตัวอย่าง
<table data-table="element-table" data-source="/api/users" data-editable-rows="true">
<thead>
<tr>
<th data-field="name" data-cell-element="text" data-placeholder="Full name"></th>
<th data-field="email" data-type="email" data-placeholder="user@example.com"></th>
<th data-field="status" data-cell-element="select" data-options='{"1":"Active","0":"Inactive"}' data-allow-empty="true"></th>
<th data-field="note" data-cell-element="textarea" data-rows="2" data-max-length="200" data-placeholder="Internal note"></th>
</tr>
</thead>
<tbody></tbody>
</table>Dynamic Columns
สร้าง table headers อัตโนมัติจาก API response โดยไม่ต้องกำหนด <thead> ใน HTML
Basic Usage
<table data-table="langEditor"
data-attr="data:translate"
data-dynamic-columns="true"
data-editable-rows="true">
<tbody></tbody>
</table>API Response Format
{
"translate": {
"columns": [
{
"field": "key",
"label": "Key",
"cellElement": "text",
"placeholder": "Enter key",
"i18n": true
},
{
"field": "th",
"label": "Thai",
"cellElement": "textarea",
"rows": 3,
"i18n": true
}
],
"data": [
{"key": "Welcome", "th": "ยินดีต้อนรับ"},
{"key": "Goodbye", "th": "ลาก่อน"}
]
}
}Column Metadata Attributes
สามารถกำหนด attributes ต่อไปนี้ใน column definition:
| Attribute | Type | Description |
|---|---|---|
field |
string | Required - ชื่อ field |
label |
string | Required - หัวข้อคอลัมน์ |
cellElement |
string | ประเภท element (text, textarea, select, color, checkbox, switch, etc) |
checkedValue |
string | ค่าที่ส่งไปเมื่อ checkbox/switch ถูกเลือก (ค่าเริ่มต้น '1') |
type |
string | ชนิดข้อมูล (text, number, date) |
placeholder |
string | Placeholder text |
class |
string | CSS class สำหรับ <th> |
cellClass |
string | CSS class สำหรับ <td> |
i18n |
boolean | เปิดใช้งานการแปลภาษา |
sort |
boolean | เปิดใช้งานการเรียงลำดับ |
filter |
boolean | เปิดใช้งานตัวกรอง — type จึงเป็นชนิดของตัวกรอง (select, tabs, date, daterange, number, text) |
showAll/allValue/allLabel |
ตัวเลือก "ทั้งหมด" ของตัวกรอง select หรือ tabs |
|
rangeNames/rangePresets/rangeMaxDays |
string | เหมือน data-range-names, data-range-presets, data-range-max-days ของตัวกรอง daterange |
formatter |
string | ชื่อ formatter function |
format |
string | รูปแบบการแสดงผล |
locale |
string | locale สำหรับ number, currency และ percent |
currency |
string | currency code/label สำหรับ format: 'currency' |
decimals |
number | จำนวนตำแหน่งทศนิยมแบบ fix สำหรับ number, currency, percent |
precision |
number | จำนวนทศนิยมที่ช่อง number รับได้ (ช่องแก้ไขในเซลล์ใช้แทน decimals ได้) |
min/max/step |
number | ขอบเขตของช่อง number — min ติดลบทำให้ใส่ค่าติดลบได้ |
options |
object | ตัวเลือกสำหรับ select |
rows/cols |
number | สำหรับ textarea |
min/max/step |
number | สำหรับ number input |
minLength/maxLength |
number | ความยาวข้อความ |
pattern |
string | RegEx pattern |
readOnly |
boolean | Read-only mode |
disabled |
boolean | Disabled state |
required |
boolean | Required field |
autoNumber |
boolean | สร้างเลขลำดับแถวแบบอัตโนมัติให้คอลัมน์นี้ |
visible |
boolean | แสดง/ซ่อนคอลัมน์ (false = ซ่อน) |
hidden |
boolean | alias ของ visible: false ใช้เพื่อความเข้ากันได้กับ data-hidden="true" |
Data Binding (data-attr)
ใช้ data-attr เพื่อ bind ข้อมูลจาก nested object ใน API response หรือจาก state ของ form/page:
รูปแบบ: data-attr="data:path.to.data"
ตัวอย่าง:
<!-- Simple binding -->
<table data-table="users" data-attr="data:users"></table>
<!-- API: {"users": [...]} -->
<!-- Nested binding -->
<table data-table="langEditor" data-attr="data:translate"></table>
<!-- API: {"translate": {"columns": [...], "data": [...]}} -->
<!-- Deep nested -->
<table data-table="monthlySales" data-attr="data:report.sales.monthly"></table>
<!-- API: {"report": {"sales": {"monthly": [...]}}} -->เมื่อ bind ผ่าน data-attr นั้น TableManager รองรับได้ทั้ง:
- array ของ rows ตรงๆ โดยใช้
<thead>ที่ประกาศไว้ใน HTML - object มาตรฐานแบบ
{data: [...], columns: [...], meta: {...}}สำหรับกรณี dynamic columns และ metadata
รูปแบบนี้เหมาะกับตารางย่อยแบบ read-only ภายในฟอร์ม เช่น payment history หรือ status log เมื่อ parent form โหลด payload มาอยู่แล้ว และต้องการให้รูปแบบการ render เหมือนตารางอื่นในระบบ admin
เลขลำดับแถวอัตโนมัติ
ใช้ data-auto-number="true" บน <th> เมื่อต้องการให้ TableManager แสดงคอลัมน์เลขลำดับแบบ reusable โดยไม่ต้องให้ backend ส่ง field อย่าง row_no, payment_no หรือชื่อใกล้เคียงมาเอง
<table data-table="payments" data-attr="data:payments">
<thead>
<tr>
<th data-auto-number="true" class="center" data-cell-class="center" data-format="number">#</th>
<th data-field="payment_method">Method</th>
<th data-field="amount" data-format="number" class="right" data-cell-class="right">Amount</th>
</tr>
</thead>
<tbody></tbody>
</table>พฤติกรรม:
- ใช้เลขลำดับแบบเริ่มจาก 1
- ถ้ามี pagination จะนับต่อข้ามหน้าให้อัตโนมัติ
- ถ้าไม่มี pagination จะนับตามลำดับ row ที่ render อยู่ในหน้านั้น
ข้อดี
✅ ยืดหยุ่น - Columns ปรับตามข้อมูลจาก API
✅ Clean HTML - ไม่ต้องเขียน <th> ซ้ำๆ
✅ Dynamic - เพิ่ม/ลด columns ได้โดยไม่แก้ frontend
✅ Type-safe - กำหนด element types ได้จาก backend
ตัวอย่างการใช้งานจริง
// PHP API Response
return [
'translate' => [
'columns' => [
['field' => 'key', 'label' => 'Key', 'cellElement' => 'text'],
['field' => 'th', 'label' => 'ไทย', 'cellElement' => 'textarea'],
['field' => 'en', 'label' => 'English', 'cellElement' => 'textarea']
],
'data' => $translations
]
];TableManager จะสร้าง header อัตโนมัติ:
<thead>
<tr>
<th data-field="key" data-cell-element="text">Key</th>
<th data-field="th" data-cell-element="textarea">ไทย</th>
<th data-field="en" data-cell-element="textarea">English</th>
</tr>
</thead>การกำหนด CSS Class แยกระหว่าง Header และ Body Cells
TableManager รองรับการกำหนด CSS class แยกกันสำหรับ <th> (header) และ <td> (body cells) โดยเด็ดขาด ไม่มี fallback
Static HTML Table
สำหรับ table ที่กำหนด <thead> ใน HTML:
<table data-table="myTable" data-source="/api/data">
<thead>
<tr>
<!-- ใช้ class ปกติสำหรับ <th> -->
<th data-field="id" class="header-mono">ID</th>
<!-- ใช้ data-cell-class สำหรับ <td> -->
<th data-field="name" class="header-bold" data-cell-class="text-bold">Name</th>
<!-- สามารถใช้แค่ data-cell-class อย่างเดียว -->
<th data-field="status" data-cell-class="badge center">Status</th>
</tr>
</thead>
<tbody></tbody>
</table>ผลลัพธ์:
<!-- Header -->
<th class="header-mono">ID</th>
<th class="header-bold">Name</th>
<th>Status</th>
<!-- Body -->
<td>001</td> <!-- ไม่มี class -->
<td class="text-bold">John Doe</td>
<td class="badge center">Active</td>Dynamic Columns (API Response)
สำหรับ table ที่ใช้ data-dynamic-columns="true":
{
"columns": [
{
"field": "id",
"label": "ID",
"class": "header-mono",
"cellClass": "mono small"
},
{
"field": "name",
"label": "Name",
"class": "header-bold",
"cellClass": "text-bold"
},
{
"field": "status",
"label": "Status",
"cellClass": "badge center"
}
],
"data": [...]
}ผลลัพธ์:
<!-- Header -->
<th class="header-mono">ID</th>
<th class="header-bold">Name</th>
<th>Status</th>
<!-- Body -->
<td class="mono small">001</td>
<td class="text-bold">John Doe</td>
<td class="badge center">Active</td>สรุป
| ประเภท | TH Class | TD Class |
|---|---|---|
| Static HTML | class="..." (attribute ปกติ) |
data-cell-class="..." |
| Dynamic API | "class": "..." (ใน JSON) |
"cellClass": "..." (ใน JSON) |
หมายเหตุสำคัญ:
- ⚠️ ไม่มี
data-classattribute - แยกกันเด็ดขาด ไม่มี fallback ระหว่าง th และ td
- สามารถใช้แค่ตัวใดตัวหนึ่ง หรือทั้งคู่ก็ได้
Footer และ Aggregate
แนวทางที่แนะนำคือเขียน tfoot แบบ explicit ไปเลย แล้วให้แต่ละ footer cell บอกหน้าที่ของตัวเอง วิธีนี้อ่านง่ายกว่า footer แบบอิงลำดับคอลัมน์ และเหมาะเมื่อคุณต้องการข้อความคงที่อย่าง Total, ตำแหน่งที่กำหนดเอง, colspan หรือ class เฉพาะใน footer
<table data-table="orders"
data-source="/api/orders"
data-show-footer="true">
<thead>
<tr>
<th data-field="id">ID</th>
<th data-field="customer">Customer</th>
<th data-field="status">Status</th>
<th data-field="amount" data-format="number">Amount</th>
<th data-field="created_at" data-format="date">Created</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<th data-field="id" data-aggregate="count" data-format="number" data-cell-class="center"></th>
<th colspan="2" class="right">Total</th>
<th data-field="amount" data-aggregate="sum" data-format="number" data-cell-class="right"></th>
<th data-field="created_at" data-aggregate="count" data-format="number" data-cell-class="center"></th>
</tr>
</tfoot>
</table>พฤติกรรมของ footer
tfootใช้ได้ทั้ง<th>และ<td>- ข้อความคงที่ใน cell จะถูกเก็บไว้ เช่น
Totalจะไม่ถูกคำนวณทับ - ถ้า footer cell ไม่ระบุ
data-fieldระบบจะ fallback ไปใช้คอลัมน์ปลายทางจากtheadตามตำแหน่ง sum,avg,min,maxจะทำงานเฉพาะเมื่อข้อมูลต้นทางเป็นตัวเลขทั้งหมดจริง ๆ- ถ้าข้อมูลเป็น text, date หรือปนกันจนไม่น่าเชื่อถือ ระบบจะข้าม cell นั้นแทนการเดาค่า
countจะนับค่าที่ไม่ว่างdata-cell-classใช้กับ footer ได้ด้วย จึงจัดแนว footer ให้ต่างจาก header ได้colspanและrowspanในtfootได้รับการรองรับ- ตารางคืนเซลล์กลับเป็น markup เริ่มต้นก่อนวาดใหม่ เฉพาะเซลล์ที่ตารางเติมเอง (
data-sum/data-aggregateหรือคอลัมน์ในdata-footer-aggregates)
เซลล์อื่นเป็นของแม่แบบ — ยอดที่ผูกกับข้อมูลของหน้าเองอยู่ครบทุกครั้งที่วาดแถว (ไม่ต้องเปิดdata-show-footer):
<form data-load-api="api/shop/order/view">
<table data-table="items" data-attr="data:items" data-url-params="false">
<thead>...</thead>
<tbody></tbody>
<tfoot>
<tr><td colspan="5" data-i18n>Sub total</td><td data-text="subtotal | number:2"></td></tr>
<tr data-if="delivery"><td colspan="5" data-i18n>Shipping fee</td><td data-text="shipping_cost | number:2"></td></tr>
</tfoot>
</table>
</form>Attribute สำหรับ footer cell
| Attribute | Description |
|---|---|
data-field |
field ต้นทางของ footer cell นี้ |
data-aggregate |
ประเภท aggregate: sum, avg, count, min, max หรือ custom |
data-format |
รูปแบบการแสดงผลของค่า aggregate |
data-formatter |
ชื่อ formatter function แบบกำหนดเอง |
data-cell-class |
CSS classes ที่จะใช้กับ footer cell หลัง render |
data-class |
ไม่รองรับ ให้ใช้ HTML class ปกติแทน |
data-prefix |
ข้อความหน้าค่าที่ format แล้ว |
data-suffix |
ข้อความท้ายค่าที่ format แล้ว |
data-align |
การจัดแนวข้อความเฉพาะ footer |
data-custom |
ชื่อ custom aggregate function เมื่อใช้ data-aggregate="custom" |
Custom formatter และ custom aggregate
footer cell ใช้ formatter แบบเดียวกับ body cell ได้:
<th data-field="amount"
data-aggregate="sum"
data-formatter="renderFooterMoney"></th>
<th data-field="amount"
data-aggregate="custom"
data-custom="calculateGrandTotal"></th>function renderFooterMoney(cell, value, rows, attrs) {
cell.textContent = `USD ${Number(value).toLocaleString()}`;
}
function calculateGrandTotal(rows, cell, table, attrs) {
return rows.reduce((sum, row) => sum + Number(row.amount || 0), 0);
}data-formatter จะได้รับ (cell, value, rows, attrs) โดยในโหมด footer นั้น attrs จะมี isFooter, field, aggregateType, tableId, column, table และ aggregate
Legacy auto mode
ถ้าไม่ต้องการข้อความหรือ layout แบบ explicit ยังสามารถใช้รูปแบบเก่าได้:
<table data-table="orders"
data-source="/api/orders"
data-show-footer="true"
data-footer-aggregates='{"amount":"sum","id":"count"}'>
<thead>...</thead>
<tbody></tbody>
<tfoot></tfoot>
</table>ในโหมดนี้ TableManager จะเติมค่าให้อัตโนมัติเฉพาะ footer cell ที่ว่างและกินพื้นที่เพียง 1 คอลัมน์เท่านั้น ถ้า cell มีข้อความอยู่แล้ว หรือถูกครอบด้วย colspan ระบบจะไม่แตะ
Column Attributes
| Attribute | Description |
|---|---|
data-field |
Field name |
data-sortable |
Enable sorting |
data-type |
Data type (text, number, date) |
data-format |
Display format |
การตั้งค่า
TableManager.init({
debug: false,
urlParams: true,
pageSizes: [10, 25, 50, 100],
showCaption: true,
showCheckbox: false,
allowRowModification: false,
rowSortable: true, // แสดง drag handle เมื่อ allowRowModification เป็น true
refreshInterval: 0 // โหลดใหม่อัตโนมัติทุกกี่วินาที · 0 = ปิด
});API อ้างอิง
TableManager.initTable(element, options)
Initialize table
| Parameter | Type | Description |
|---|---|---|
element |
HTMLTableElement | Table element |
options |
object | Configuration |
TableManager.setData(tableId, data)
Set table data
| Parameter | Type | Description |
|---|---|---|
tableId |
string | Table ID |
data |
array | Data array |
TableManager.setData('users-table', [
{ id: 1, name: 'John', email: 'john@example.com' },
{ id: 2, name: 'Jane', email: 'jane@example.com' }
]);TableManager.loadTableData(tableId, options?)
Reload data from source
await TableManager.loadTableData('users-table');TableManager.renderTable(tableId)
Re-render table
TableManager.renderTable('users-table');TableManager.setRefreshInterval(tableId, seconds)
เปลี่ยนความถี่ที่ตารางโหลดตัวเองใหม่ หรือสั่งหยุด · ดูโหลดข้อมูลใหม่อัตโนมัติ
| Parameter | Type | Description |
|---|---|---|
tableId |
string | Table ID |
seconds |
number | โหลดใหม่ทุกกี่วินาที · 0 = หยุด |
คืนค่า: boolean — ตอนนี้มีตัวจับเวลาทำงานอยู่หรือไม่
ค่าที่ตั้งถูกเก็บไว้ใน config ของตาราง การเรียก startAutoRefresh() ภายหลังจึงยังใช้ค่าเดิม
// ผูกกับ dropdown "รีเฟรชทุก…"
picker.addEventListener('change', () => {
TableManager.setRefreshInterval('logLines', Number(picker.value));
});TableManager.getRefreshInterval(tableId)
คืนค่า: number — โหลดใหม่ทุกกี่วินาที · 0 เมื่อปิดอยู่
if (TableManager.getRefreshInterval('logLines') === 0) {
// การรีเฟรชปิดอยู่
}TableManager.startAutoRefresh(tableId)
เริ่มตัวจับเวลาด้วยค่า refreshInterval ปัจจุบันของตาราง · initTable() เรียกให้อยู่แล้ว
จึงต้องเรียกเองเฉพาะตอนที่ไปแก้ config ด้วยมือ · ไม่ทำอะไรเลยถ้าค่าเป็น 0
หรือตารางไม่มี data-source
คืนค่า: boolean — ตอนนี้มีตัวจับเวลาทำงานอยู่หรือไม่
TableManager.stopAutoRefresh(tableId)
หยุดตัวจับเวลา · เรียกได้แม้ไม่มีตัวจับเวลาทำงานอยู่ · destroyTable() เรียกให้เองแล้ว
TableManager.setFilters(tableId, filters)
Set filter values
TableManager.setFilters('users-table', {
name: 'John',
status: 'active'
});TableManager.clearSelection(table, tableId, options?)
Clear row selection
TableManager.enableRowSort(tableId)
เปิดการลากวางเรียงแถว Drag handle จะแสดงเมื่อ data-row-sortable="true" ถูกกำหนด (ไม่จำเป็นต้องเปิด data-editable-rows)
TableManager.disableRowSort(tableId)
ปิดการลากวางและซ่อน drag handle
TableManager.toggleColumnVisibility(tableId, fieldName, visible)
ซ่อน/แสดงคอลัมน์แบบ dynamic
พารามิเตอร์:
tableId(string) - รหัสตารางfieldName(string) - ชื่อฟิลด์ของคอลัมน์visible(boolean) -true= แสดง,false= ซ่อน
ตัวอย่าง:
// ซ่อนคอลัมน์ email
TableManager.toggleColumnVisibility('users', 'email', false);
// แสดงคอลัมน์ email กลับมา
TableManager.toggleColumnVisibility('users', 'email', true);TableManager.clearTableCache(tableId)
ลืมสิ่งที่ตารางจำไว้ทั้งหมด: ความกว้างคอลัมน์ ขนาดหน้า การเรียง ตัวกรอง เลขหน้า
และสถานะล่าสุดที่เก็บไว้สำหรับการรีโหลด (ดู จดจำสถานะของตาราง) · หน้าจอปัจจุบันไม่เปลี่ยน
ครั้งต่อไปจะเริ่มจากค่าเริ่มต้น
// ปุ่ม "คืนค่าหน้าจอ"
TableManager.clearTableCache('users');การลากเรียงแถว (drag & drop)
- เปิดใช้: ใส่
data-row-sortable="true"เพื่อแสดง drag handle (⋮⋮) และเปิดการลากเรียงลำดับแถว - ไม่จำเป็นต้องใช้
data-editable-rows: คุณสมบัติทั้งสองแยกทำงานกันอย่างอิสระdata-editable-rows="true"= เปิดปุ่มเพิ่ม/ลบแถวdata-row-sortable="true"= เปิดลากเรียงลำดับแถว
- ปิดเป็นรายตาราง: เรียก
TableManager.disableRowSort(tableId) - UX ล่าสุด: ghost ขนาดเล็กตามเคอร์เซอร์, placeholder เป็นแถวเส้นประ, การลากล็อกแนวตั้งเพื่อให้ reorder แม่นยำ
<table data-table="rows"
data-source="/api/rows"
data-row-sortable="true">
<thead>
<tr>
<th data-field="name">Name</th>
<th data-field="position">Position</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="controls">
<button onclick="TableManager.disableRowSort('rows')">ปิด drag</button>
<button onclick="TableManager.enableRowSort('rows')">เปิด drag</button>
</div>TableManager.exportData(tableId, format, options)
ส่งออกข้อมูลตารางเป็นไฟล์
| Parameter | Type | Description |
|---|---|---|
tableId |
string | Table ID |
format |
string | รูปแบบไฟล์: 'csv', 'json', หรือ 'excel' |
options |
object | ตัวเลือกเพิ่มเติม |
| Options: | Option | Type | Description |
|---|---|---|---|
filename |
string | ชื่อไฟล์ที่จะบันทึก |
หมายเหตุ:
- Export ใช้ข้อมูลที่ผ่านการกรอง/เรียงลำดับแล้ว (ตรงกับที่ผู้ใช้เห็น)
- ค่าที่ export จะถูก format แล้ว (เช่น แสดง "Engineering" แทน "1")
- Header ใช้ชื่อคอลัมน์จาก
<th>แทน field name
// Export เป็น CSV
TableManager.exportData('users-table', 'csv', {
filename: 'employees.csv'
});
// Export เป็น JSON
TableManager.exportData('users-table', 'json', {
filename: 'employees.json'
});Bulk Actions (Checkbox Selection)
ระบบจัดการ bulk actions สำหรับแถวที่ถูกเลือกผ่าน checkbox พร้อม dropdown เลือก action และปุ่ม submit
การใช้งาน
<table data-table="deals"
data-source="api/crm/deals"
data-show-checkbox="true"
data-actions='{"delete":"Delete","activate":"Activate","stage|lead":"Lead","stage|won":"Won"}'
data-action-url="api/crm/deals/action"
data-action-button="Process|btn-success">
<!-- table content -->
</table>Attributes
| Attribute | Description |
|---|---|
data-show-checkbox |
แสดง checkbox สำหรับเลือกแถว |
data-actions |
JSON object ของ actions (key: action value, value: label) |
data-action-url |
URL สำหรับส่ง action (POST) |
data-action-button |
ข้อความปุ่ม หรือ "label\|className" |
รูปแบบ Action Key
Simple Action:
{"delete": "Delete", "activate": "Activate"}ส่ง: {action: "delete", ids: [...]}
Compound Action (ใช้ | separator):
{"stage|lead": "Lead", "stage|won": "Won", "stage|lost": "Lost"}ส่ง: {action: "stage", stage: "lead", ids: [...]}
รูปแบบ action|value จะแยก key เป็น:
action= ส่วนแรก (ก่อน|)- ส่วนแรกเป็นชื่อ parameter เพิ่มเติม โดยมี value = ส่วนหลัง (หลัง
|)
ตัวอย่างการใช้งานจริง
<table data-table="deals"
data-source="api/crm/deals"
data-show-checkbox="true"
data-actions='{
"stage|lead":"Lead",
"stage|qualified":"Qualified",
"stage|proposal":"Proposal",
"stage|negotiation":"Negotiation",
"stage|won":"Won",
"stage|lost":"Lost",
"delete":"Delete"
}'
data-action-url="api/crm/deals/action"
data-action-button="Process|btn-success">
<thead>
<tr>
<th data-field="title">Title</th>
<th data-field="stage">Stage</th>
</tr>
</thead>
<tbody></tbody>
</table>Backend Handler (PHP)
public function action()
{
$action = $this->request->post('action');
$ids = $this->request->post('ids');
switch ($action) {
case 'stage':
$stage = $this->request->post('stage'); // "lead", "won", etc.
$this->model->updateStage($ids, $stage);
break;
case 'delete':
$this->model->deleteByIds($ids);
break;
}
return ['success' => true];
}Event
EventManager.on('table:actionComplete', (e) => {
const {tableId, action, ids, response} = e.detail;
console.log(`Action "${action}" completed for ${ids.length} rows`);
});Filter Actions (Table-Level Actions)
ปุ่มและลิงก์ที่ส่ง filter parameters ไปยัง action URL - สำหรับ export, report generation, หรือ bulk operations
การใช้งาน
<table data-table="my-table"
data-source="api/users"
data-filter-actions='{
"export": {
"label": "Export Filtered",
"url": "api/export",
"type": "button",
"className": "btn-primary"
},
"report": {
"label": "View Report",
"url": "/reports",
"type": "link",
"className": "btn-info",
"target": "_blank"
}
}'>
<!-- table content -->
</table>ตัวเลือก Filter Actions
| Property | Type | Default | Description |
|---|---|---|---|
label |
string | key name | ข้อความแสดงบน button/link |
url |
string | required | URL สำหรับ action |
type |
string | "button" |
"button" (POST) หรือ "link" (GET redirect) |
className |
string | "" |
CSS classes เพิ่มเติม |
target |
string | "_self" |
สำหรับ link: "_blank" เปิด tab ใหม่ |
confirm |
string | - | ข้อความยืนยันก่อนทำ action |
Button Behavior
เมื่อกดปุ่ม จะส่ง POST request ไปยัง URL พร้อมข้อมูล:
{
"action": "export",
"tableId": "my-table",
"filters": {"status": "1", "department": "5"},
"sort": {"name": "asc"}
}Link Behavior
เมื่อกดลิงก์ จะ redirect ไปยัง URL พร้อม filter params ใน query string:
/reports?status=1&department=5Event
ส่งผ่าน EventManager.emit() ซึ่งไม่ได้ dispatch DOM event จึงต้องดักด้วย
EventManager.on() ไม่ใช่ addEventListener():
EventManager.on('table:filterAction', (data) => {
const {tableId, action, type, params} = data;
console.log(`Filter action: ${action} (${type})`);
console.log('Filters:', params);
});type เป็น 'link' (payload มี url มาด้วย) หรือ 'button' (payload มี
response มาด้วย)
เงื่อนไขของปุ่มประจำแถว
ทุกรายการใน data-row-actions ใส่ condition ได้ — เป็นนิพจน์ที่ประเมินกับข้อมูลของแถวนั้นเอง
เพื่อตัดสินว่าจะสร้างปุ่มให้แถวนั้นหรือไม่ ปุ่มที่ไม่ใส่ condition จะแสดงเสมอ
<table data-table="sftpAccounts" data-source="/api/v2/sftp/accounts"
data-row-actions='{
"manage": {
"title": "Manage",
"className": "btn btn-success icon-edit",
"method": "get",
"url": "/app/user?id={id}",
"condition": "${can_manage}"
},
"disable": {
"title": "Disable",
"className": "btn btn-danger icon-ban",
"method": "delete",
"url": "/api/v2/users/{id}/sftp",
"condition": "${can_manage && sftp_enabled}",
"confirm": "Disable SFTP for this account?"
}
}'>ชื่อฟิลด์มาจากแถว และเขียนตรง ๆ ไม่ต้องมีคำนำหน้า sftp_enabled หมายถึง sftp_enabled
ของแถวนั้น ไม่ใช่ตัวแปรระดับหน้า — ปุ่มประจำแถวไม่เห็นขอบเขตของหน้าเลย สิ่งที่ใช้ตัดสินใจจึงต้องเป็น
คอลัมน์ที่ปลายทางส่งมาด้วย (can_manage ข้างบนคือสิ่งนั้น: สำเนาสิทธิ์ของผู้ดูรายแถว)
${...} จะใส่หรือไม่ใส่ก็ได้ และวางตรงไหนก็ให้ผลเดียวกัน สามบรรทัดนี้คือเงื่อนไขเดียวกัน:
"condition": "${can_manage && sftp_enabled}"
"condition": "${can_manage} && ${sftp_enabled}"
"condition": "can_manage && sftp_enabled"ใส่เป็นบูลีนตรง ๆ ก็ได้ ("condition": true) ซึ่งมีประโยชน์ตอนที่เซิร์ฟเวอร์ประกอบค่าคอนฟิกของปุ่มมาเอง
การซ่อนปุ่มคือเรื่องการแสดงผล ไม่ใช่การป้องกัน — ข้อมูลยังอยู่ในแถวและปลายทางยังเรียกถึงได้
ต้องตรวจสิทธิ์ซ้ำที่เส้นทางเบื้องหลังเสมอ
เหตุการณ์
| Event | เมื่อเกิด | Detail |
|---|---|---|
table:render |
เรนเดอร์ตารางเสร็จแต่ละรอบ | {tableId, data, totalRecords, filteredRecords, isServerSide} |
table:refreshed |
หลังโหลดใหม่อัตโนมัติแต่ละรอบ | {tableId, automatic, timestamp} |
table:selectionChange |
เลือก/ยกเลิกเลือกแถว | {tableId, selectedRows} |
table:fieldChange |
แก้ค่าในเซลล์ที่แก้ไขได้ | {tableId, field, value, rowData, element} |
table:rowAction |
กดปุ่ม action ในแถว | {tableId, action, payload, row} |
table:rowCopied |
คัดลอกแถวสำเร็จ | {tableId, originalItem, newItem} |
table:rowDeleted |
ลบแถวสำเร็จ | {tableId, item} |
table:rowRemoved |
ถอดแถวออกจาก DOM หลัง bulk action | {tableId, rowId} |
table:action |
ส่ง action ไป endpoint แล้วได้ผลกลับ | {tableId, action, success, response, timestamp} |
table:filterAction |
กดปุ่ม filter action | {tableId, action, type, url, params} |
table:export |
export เสร็จ | {tableId, format, success} |
table:footerSetup |
สร้าง footer เสร็จ | {tableId, tfoot, data} |
table:stateUpdate |
ข้อมูลที่ผูกกับ state manager เปลี่ยน | {tableId, statePath, recordCount, timestamp} |
table:bound |
ผูกตารางเข้ากับ state สำเร็จ | {tableId, statePath, timestamp} |
table:error |
เกิดข้อผิดพลาดระหว่างทำ action | {tableId, action, error, timestamp} |
table:destroyed |
ทำลายตารางหนึ่งตัว | {tableId, timestamp} |
tablemanager:destroyed |
destroy() ทำลายทุกตาราง | {timestamp} |
EventManager.on('table:loaded', (e) => {
console.log(`Loaded ${e.detail.data.length} rows`);
});Row Template
<table data-component="table" data-source="/api/users">
<thead>
<tr>
<th data-field="name">Name</th>
<th data-field="email">Email</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template>
<tr data-id="{{id}}">
<td>{{name}}</td>
<td>{{email}}</td>
<td>
<button data-action="edit" data-id="{{id}}">Edit</button>
<button data-action="delete" data-id="{{id}}">Delete</button>
</td>
</tr>
</template>
</tbody>
</table>การจัดรูปแบบ CSS
/* Table container */
.table-container {
background: white;
border-radius: 8px;
overflow: hidden;
}
/* Table */
[data-component="table"] {
width: 100%;
border-collapse: collapse;
}
[data-component="table"] th,
[data-component="table"] td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
/* Sortable header */
th[data-sortable] {
cursor: pointer;
user-select: none;
}
th[data-sortable]:hover {
background: #f3f4f6;
}
th[data-sortable].asc::after { content: ' ↑'; }
th[data-sortable].desc::after { content: ' ↓'; }
/* Selected row */
tr.selected {
background: rgba(59, 130, 246, 0.1);
}ข้อควรระวัง
⚠️ 1. อย่าใช้ business id เป็น row identity ภายใน
สำหรับตารางที่มี data-editable-rows="true" ตอนนี้ TableManager จะสร้าง row key ภายในที่คงที่เอง เพื่อให้การเพิ่ม/ลบ/คัดลอกแถวไม่พึ่งค่าจากฟิลด์ id ที่ผู้ใช้กำลังแก้ไข
// ✅ ไม่มี business id ก็ยังเพิ่ม/ลบแถวได้
{
"userstatus": [
{"status": 0, "color": "#000", "topic": "Member"},
{"status": 1, "color": "#333", "topic": "Admin"}
]
}
// ✅ มี business id ก็ได้ และแก้ค่า id ในตารางได้โดยไม่ทำให้ row เพี้ยน
{
"userstatus": [
{"id": 0, "status": 0, "color": "#000", "topic": "Member"},
{"id": 1, "status": 1, "color": "#333", "topic": "Admin"}
]
}เหตุผล: ถ้าใช้ค่าจากคอลัมน์ id ที่ผู้ใช้แก้ได้โดยตรงเป็นตัวชี้แถวใน DOM พอกด +/- หลังแก้ id แล้วอาจหาแถวผิดตัวได้ ปัจจุบัน TableManager จึงใช้ row key ภายในที่คงที่แทน
ข้อแนะนำ:
- ถ้า backend ของคุณต้องมี field
idก็ส่งมาได้ตามปกติ แต่ให้มองว่าเป็น business data ไม่ใช่ DOM identity - ถ้า backend ไม่ต้องใช้
idสำหรับ schema ของข้อมูล ก็ไม่จำเป็นต้องเติมidแค่เพื่อให้ปุ่ม+/-ทำงาน
กรณี backend ต้องใช้ field id:
// ส่ง id ตาม business schema ได้ตามปกติ
foreach ($data as $key => $value) {
$result[] = [
'id' => $key,
'status' => $key,
'name' => $value
];
}⚠️ 2. รูปแบบ Element ID สำหรับ Error Highlighting
Element ภายในเซลล์มีชื่อ {field}[{rowKey}] และมี ID เป็น {tableId}_{field}_{rowKey} เพื่อให้ API ชี้ error ไปที่เซลล์เดียวได้ ในตารางที่แก้ไขได้ (data-editable-rows="true") rowKey คือ key สังเคราะห์ เช่น row_2 ซึ่งคงเดิมแม้ผู้ใช้จะแก้ค่าคอลัมน์ id ในแถวเดียวกัน ส่วนตารางอื่นใช้ id ของแถว หรือลำดับแถวเมื่อไม่มี id
// ✅ เซลล์ของแถว row_2 ในตาราง "userStatus"
name: "topic[row_2]"
id: "userStatus_topic_row_2" // tableId_field_rowKey
// ✅ API Error Response
{
"errors": {
"userStatus_topic_row_2": "Name is required" // ตรงกับ element ID
}
}การทำงาน:
- FormManager จะอ่าน error keys และ highlight element ที่ id ตรงกัน
- รูปแบบ:
{tableId}_{fieldName}_{rowKey} - ถ้า API ส่ง key ที่ตรงกับ element ID จะเห็น error highlight ที่ฟิลด์นั้น
รู้ได้อย่างไรว่าเป็นตารางไหน — _editable_tables[]: ข้อมูลที่ POST มามีแค่ topic[row_2] ไม่มี id ของตาราง ตารางที่แก้ไขได้ซึ่งอยู่ในฟอร์มจึงเพิ่ม hidden field _editable_tables[] ที่เก็บ id ของตารางไว้ (หนึ่งช่องต่อหนึ่งตารางที่แก้ไขได้ในฟอร์ม และถูกลบออกเมื่อตารางถูก destroy) ให้ server ใช้สร้าง key ของ error ส่วนชื่อของเซลล์ไม่เปลี่ยน
$table = $request->post('_editable_tables', [])->topic()[0] ?? '';
$errors = [];
foreach ($request->post('topic', [])->topic() as $rowKey => $topic) {
if ($topic === '') {
$errors[$table.'_topic_'.$rowKey] = 'Please fill in';
}
}
if (!empty($errors)) {
return $this->formErrorResponse($errors);
}⚠️ 3. Caption ปิดอัตโนมัติสำหรับ Editable Tables
ตารางที่มี data-editable-rows="true" จะปิด caption อัตโนมัติ:
<!-- ✅ Caption ปิดอัตโนมัติ ไม่ต้องระบุ data-show-caption -->
<table data-table="userStatus" data-editable-rows="true">
<!-- ✅ บังคับให้แสดง caption (ถ้าต้องการ) -->
<table data-table="userStatus" data-editable-rows="true" data-show-caption="true">เหตุผล: Editable tables แสดงข้อมูลทั้งหมดโดยไม่มี pagination ดังนั้น caption ที่บอก "page 1 of 1" จึงไม่จำเป็น
หมายเหตุ: คำเตือนนี้ปรับใหม่เนื่องจาก caption ถูกปิดอัตโนมัติแล้ว ไม่ต้องตั้งค่าเอง
⚠️ 2. Caption กับตารางแบบแก้ไขได้
ตารางที่มี data-editable-rows="true" และไม่มี pagination ควรปิด caption:
<!-- ✅ ปิด caption เพราะไม่มี pagination -->
<table data-table="userStatus"
data-editable-rows="true"
data-show-caption="false">เหตุผล: Caption แสดงข้อความแบบ "All 5 entries, displayed 1 to 5, page 1 of 1 pages" ซึ่งไม่เหมาะกับตารางแก้ไขได้ที่แสดงข้อมูลทั้งหมด
⚠️ 3. Field Names ต้องตรงกับ Data
<!-- ❌ Field ไม่ตรง -->
<th data-field="userName">Name</th>
<!-- data: { name: 'John' } -->
<!-- ✅ Field ตรง -->
<th data-field="name">Name</th>⚠️ 2. Template ใน tbody
- ⚠️ ช่องกรอกข้อความจะใช้
maxLengthเมื่อค่ามากกว่า 0 เท่านั้น; ให้ตั้งเลขบวกหรือไม่ระบุเพื่อไม่จำกัด
<!-- ❌ ไม่มี template -->
<tbody>
<tr><td>Static row</td></tr>
</tbody>
<!-- ✅ มี template -->
<tbody>
<template>
<tr><td>{{field}}</td></tr>
</template>
</tbody>การจัดการคำตอบ 403 / 401
การโหลดข้อมูลของ data-table เอง (data-source) จัดการ 403/401 แบบเดียว
กับ ApiComponent — แต่เป็นคนละ
ระบบคอมโพเนนต์ คนละเส้นทางคำขอกันโดยสิ้นเชิง จึงเป็นโค้ดที่เขียนแยกต่างหากจริง
ไม่ได้ใช้ร่วมกัน:
| สถานะ | พฤติกรรมเริ่มต้น |
|---|---|
403 Forbidden |
RouterManager.navigate() ไปที่ RouterManager.config.auth.redirects.forbidden ถ้าไม่ได้ตั้งค่าจะ fallback ไปที่ /forbidden |
401 Unauthorized |
RouterManager.navigate('/login') |
กติกาเดียวกับ ApiComponent: ก่อนจะทำอะไรกับคำตอบทั้งสองแบบ TableManager จะเช็ค
ก่อนว่าตารางนั้นยังติดอยู่กับหน้าจริงหรือไม่ (TableManager.isAlive(tableId) —
ยังถูกติดตามอยู่ใน state.tables และ element <table> ของมันยังติดอยู่กับ
document.body) ตาราง data-table ที่ฝังอยู่ในกล่องที่คุมด้วย data-if (เช่น
ตารางเฉพาะแอดมินที่อยู่ในหน้าที่คนทั่วไปก็เข้าได้) ยังคงยิงคำขอทันทีที่ถูกสร้าง
— การซ่อนกล่องครอบไม่ได้หยุดคำขอเลย — ถ้าไม่มีการเช็คนี้ ตารางที่ผู้ใช้ไม่เคย
เห็นเลยก็ยัง redirect ทั้งหน้าได้จากคำตอบ 403 ที่ไม่มีความหมายแล้วของมันเอง
ปุ่ม row action (data-row-actions) กับการส่งฟอร์มกรอง (data-table-filter)
จัดการ 403/401 ต่างออกไปโดยตั้งใจ: นั่นคือคำตอบของสิ่งที่ผู้ใช้กดเองตรง ๆ จึง
แสดง notification (403) หรือส่งผ่าน AuthErrorHandler (401) แทนการ navigate
ออกไป — การเสียเนื้อหาทั้งหน้าเพราะกดปุ่มเดียวแล้วล้มเหลวจะแย่กว่าปุ่มนั้นแค่
ใช้ไม่ได้
เอกสารที่เกี่ยวข้อง
- ApiService - API calls
- Sortable - Drag-drop