Now.js Framework Documentation

Now.js Framework Documentation

TableManager

TH 04 Sep 2026 01:40

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

<div data-table-toolbar="users-table">
  <input data-filter="name" placeholder="Search name...">
  <select data-filter="status">
    <option value="">All</option>
    <option value="active">Active</option>
    <option value="inactive">Inactive</option>
  </select>
</div>

<table id="users-table" data-component="table" data-source="/api/users">
  ...
</table>

ฟอร์มกรองภายนอก (data-table-filter)

คุณสามารถสร้างฟอร์มกรองแยกจากตาราง แล้วเชื่อมด้วย 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 ของตารางหลังโหลดข้อมูลสำเร็จ
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 |
| 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 ให้หน้าจอเอาไปแสดงเวลาที่ข้อมูลมาถึงล่าสุดได้

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 ได้ทันที
  • 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 ของคอลัมน์นั้น

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" และคอลัมน์ของตาราง

ตัวอย่างแบบ 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 ตามลำดับเดิม
  • built-in formats ที่ไม่ใช่ lookup จะอ่าน attributes ของคอลัมน์โดยตรง จึงประกาศ decimals, currency, locale, pattern แยกต่อคอลัมน์ได้
  • ใช้ formatter เมื่อเซลล์ต้องการ logic ที่อิงทั้งแถว, custom HTML หรือ pipeline การ render ที่กำหนดเอง

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 (number), datalist, pattern, min-length, max-length (ใช้เมื่อค่ามากกว่า 0 เท่านั้น)

ตัวอย่าง

<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 เปิดใช้งานตัวกรอง
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
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 ได้รับการรองรับ
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);

การลากเรียงแถว (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 ภายในเซลล์ใช้รูปแบบ ID แบบ tableId_field_rowKey เพื่อให้ API สามารถ highlight ฟิลด์ที่มี error ได้อย่างคงที่ แม้ผู้ใช้จะแก้ค่าคอลัมน์ id ในแถวเดียวกัน:

// ✅ Element ID (predictable)
"userStatus_topic_row_2"  // tableId_field_rowKey

// ✅ API Error Response
{
  "errors": {
    "userStatus_topic_2": "Name is required"  // ตรงกับ element ID
  }
}

การทำงาน:

  • FormManager จะอ่าน error keys และ highlight element ที่ id ตรงกัน
  • รูปแบบ: {tableId}_{fieldName}_{rowId}
  • ถ้า API ส่ง key ที่ตรงกับ element ID จะเห็น error highlight ที่ฟิลด์นั้น

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

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