Now.js Framework Documentation

Now.js Framework Documentation

TableManager

TH 09 Nov 2026 03:28

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 ที่ PHP Language::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-if
  • data-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 สำหรับ &lt;th&gt;
cellClass string CSS class สำหรับ &lt;td&gt;
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-class attribute
  • แยกกันเด็ดขาด ไม่มี fallback ระหว่าง th และ td
  • สามารถใช้แค่ตัวใดตัวหนึ่ง หรือทั้งคู่ก็ได้

แนวทางที่แนะนำคือเขียน 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>
  • 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 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"}
}

เมื่อกดลิงก์ จะ redirect ไปยัง URL พร้อม filter params ใน query string:

/reports?status=1&department=5

Event

ส่งผ่าน 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
ออกไป — การเสียเนื้อหาทั้งหน้าเพราะกดปุ่มเดียวแล้วล้มเหลวจะแย่กว่าปุ่มนั้นแค่
ใช้ไม่ได้

เอกสารที่เกี่ยวข้อง