Now.js Framework Documentation
TableManager
TableManager
Overview
TableManager is the data table management system in Now.js Framework. It supports sorting, filtering, pagination, and API integration.
When to use:
- Need data tables
- Need sorting and filtering
- Need pagination
- Need CRUD operations
- Need row selection
Why use it:
- ✅ Auto-load from API
- ✅ Sorting (client/server)
- ✅ Filtering
- ✅ Pagination
- ✅ Row selection (checkbox)
- ✅ Inline editing
- ✅ URL state persistence
- ✅ Sortable rows (drag & drop)
- ✅ Drag UX: icon ghost at cursor, dashed placeholder row, vertical-only drag
Basic Usage
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>External Filter Form (data-table-filter)
You can build a separate filter form and link it to a table via data-table-filter="tableId". This keeps the form layout flexible (including range filters) while the table still loads/syncs state, URL params, and API options automatically.
<!-- 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>
<!-- Range filter: date -->
<input type="date" name="from">
<input type="date" name="to">
<!-- Optional search -->
<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>How it works
data-table-filter="tableId"links the form to the table (tabledata-tablemust match).- Submit the form to apply filters; TableManager prevents full page reload and reloads data.
- Range filters work by naming pairs (e.g.,
from/to,price_min/price_max). All non-empty fields are sent to the API. - URL parameters stay in sync and restore form values on page load.
- If the API returns
options(e.g.,{ status: [{value:'active', text:'Active'}] }), select elements with matchingnameare populated automatically.
API payload example
GET /api/index/usage?page=1&pageSize=25&from=2024-01-01&to=2024-12-31&search=testNotes
- Internal header-based filters still work; when an external form exists, TableManager still reads
<th>metadata (defaults/options) but uses the external form UI. - Keep field names consistent with API parameters (e.g.,
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 |
Enable caching for GET requests from data-source |
data-cache-time |
Cache TTL in milliseconds for data-source |
data-load-target |
CSS selector of a container that should be rebound from the normalized table payload after data loads |
data-page-size |
Items per page |
data-show-footer |
Enable footer processing |
data-footer-aggregates |
Legacy auto-footer aggregate mapping for blank tfoot cells |
When a table mutation succeeds and TableManager reloads the remote source, that reload bypasses cache automatically so post-action data stays fresh.
| data-page-sizes | Available page sizes |
| data-default-sort | Default sort order (e.g., "name asc" or "created_at desc,name asc") |
| data-show-checkbox | Show row checkboxes |
| data-show-caption | Show/hide caption (default: true, but auto-disabled when using data-editable-rows="true") |
| data-editable-rows | Enable row editing (add/delete) |
| data-row-sortable | Enable row drag-drop reordering (works independently from data-editable-rows) |
| data-sortable-rows | Enable drag-drop reorder |
| data-url-params | Persist state in URL |
Declarative Load Target Binding
Use data-load-target when a table response should also hydrate another container without custom page JavaScript. After TableManager normalizes the loaded payload, it passes that payload into the target through TemplateManager.processTemplate() and processDataOnLoad().
This is useful for headers, summary cards, result counters, empty states, and side panels that should react to the same API response as the table.
Bound state
The target receives the normalized payload on context.state, including:
{
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: {/* original payload */}
}The primary rows array is also exposed on context.data.
Example: Update header from table 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>Notes
data-load-targetruns after data normalization, so the target can usedata,meta,filters,options, and current table params immediately.- The target can use normal TemplateManager directives such as
data-text,data-attr,data-class,data-if,data-for, anddata-on-load. - The target is rebound every time the table reloads from API or state through TableManager.
- Prefer using API fields directly when the server already knows the correct label or summary. Use
params.*only for values derived from current table state or URL params.
Hide/Show Columns
You can hide columns in 2 ways:
1. Use data-visible="false" or data-hidden="true" in 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" is an alias of data-visible="false". Use either form in declarative HTML; both hide the column header and its body cells.
2. Use JavaScript dynamically
// Hide column
TableManager.toggleColumnVisibility('users', 'email', false);
// Show column
TableManager.toggleColumnVisibility('users', 'email', true);Note: Hidden columns won't display in <th> or <td> but data remains in memory.
Built-in Cell Formatting
Use data-format on a column <th> when you want TableManager to format display text without a custom formatter.
| Attribute | Applies to | Description |
|---|---|---|
data-format |
HTML headers and dynamic columns | Built-in format such as text, number, currency, percent, lookup, date, time, datetime, bytes, duration, boolean, humanize, or truncate |
data-decimals |
number, currency, percent |
Fixed number of decimal places |
data-locale |
number, currency, percent |
Locale passed to Intl.NumberFormat |
data-currency |
currency |
Currency code/label passed to the shared currency formatter |
data-pattern |
date, time, datetime |
Display pattern passed to Utils.date.format() |
data-options |
lookup |
Fallback lookup options when API/filter options are unavailable |
currency columns now use the same shared formatter contract as template bindings, so decimal behavior is consistent between data-text="amount | currency:THB:th-TH:4" and table columns.
Static HTML example
<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 column example
{
"columns": [
{
"field": "quantity",
"label": "Quantity",
"format": "currency",
"decimals": 4
},
{
"field": "total_cost",
"label": "Total",
"format": "currency",
"currency": "THB",
"locale": "th-TH",
"decimals": 2
}
],
"data": []
}Notes:
lookupformatting still resolves options from API response options, filter options, ordata-options.- Non-lookup built-in formats read column attributes directly, so
decimals,currency,locale, andpatterncan be declared per column. - Use
formatterwhen the cell needs row-aware logic, custom HTML, or a completely custom rendering pipeline.
Cell Elements (ElementManager)
Render form controls inside cells using data-cell-element (or data-type as a shorthand). TableManager passes the attributes to ElementManager and stores data-element-id on the cell.
Supported attributes: 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 (applies only when > 0).
Example
<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
Automatically generate table headers from API response without defining <thead> in 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": "en",
"label": "English",
"cellElement": "textarea",
"rows": 3,
"i18n": true
}
],
"data": [
{"key": "Welcome", "en": "Welcome"},
{"key": "Goodbye", "en": "Goodbye"}
]
}
}Column Metadata Attributes
You can specify the following attributes in column definitions:
| Attribute | Type | Description |
|---|---|---|
field |
string | Required - Field name |
label |
string | Required - Column header label |
cellElement |
string | Element type (text, textarea, select, color, etc) |
type |
string | Data type (text, number, date) |
placeholder |
string | Placeholder text |
class |
string | CSS class for <th> |
cellClass |
string | CSS class for <td> |
i18n |
boolean | Enable translation |
sort |
boolean | Enable sorting |
filter |
boolean | Enable filtering |
formatter |
string | Formatter function name |
format |
string | Display format |
locale |
string | Locale for number, currency, and percent formatting |
currency |
string | Currency code/label for format: 'currency' |
decimals |
number | Fixed decimal places for number, currency, and percent |
options |
object | Options for select |
rows/cols |
number | For textarea |
min/max/step |
number | For number input |
minLength/maxLength |
number | Text length constraints |
pattern |
string | RegEx pattern |
readOnly |
boolean | Read-only mode |
disabled |
boolean | Disabled state |
required |
boolean | Required field |
autoNumber |
boolean | Auto-generate a 1-based row number for this column |
visible |
boolean | Show/hide the column (false hides it) |
hidden |
boolean | Alias for visible: false, useful for compatibility with declarative data-hidden="true" |
Data Binding (data-attr)
Use data-attr to bind data from nested objects in API response or form/page state:
Format: data-attr="data:path.to.data"
Examples:
<!-- 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": [...]}}} -->When bound through data-attr, TableManager accepts either:
- A plain array of rows, using the
<thead>already defined in HTML. - A canonical object such as
{data: [...], columns: [...], meta: {...}}for dynamic columns and metadata.
This is useful for read-only sub-tables inside forms, such as payment history or status logs, where the parent form already loaded the full payload and you want to keep the rendering pattern consistent with the rest of the admin UI.
Auto Row Numbering
Use data-auto-number="true" on a <th> when you want TableManager to render a reusable row sequence column without requiring the backend to send row_no, payment_no, or similar fields.
<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>Behavior:
- Uses 1-based numbering.
- When pagination is active, numbering continues across pages.
- When pagination is not active, numbering uses the local rendered row index.
Benefits
✅ Flexible - Columns adapt to API data
✅ Clean HTML - No repetitive <th> tags
✅ Dynamic - Add/remove columns without frontend changes
✅ Type-safe - Define element types from backend
Real-world Example
// PHP API Response
return [
'translate' => [
'columns' => [
['field' => 'key', 'label' => 'Key', 'cellElement' => 'text'],
['field' => 'th', 'label' => 'Thai', 'cellElement' => 'textarea'],
['field' => 'en', 'label' => 'English', 'cellElement' => 'textarea']
],
'data' => $translations
]
];TableManager will auto-generate headers:
<thead>
<tr>
<th data-field="key" data-cell-element="text">Key</th>
<th data-field="th" data-cell-element="textarea">Thai</th>
<th data-field="en" data-cell-element="textarea">English</th>
</tr>
</thead>
---
### Separate CSS Classes for Header and Body Cells
TableManager supports separate CSS class definitions for `<th>` (header) and `<td>` (body cells) with no fallback between them.
#### Static HTML Table
For tables with `<thead>` defined in HTML:
```html
<table data-table="myTable" data-source="/api/data">
<thead>
<tr>
<!-- Use standard class attribute for <th> -->
<th data-field="id" class="header-mono">ID</th>
<!-- Use data-cell-class for <td> -->
<th data-field="name" class="header-bold" data-cell-class="text-bold">Name</th>
<!-- Can use data-cell-class only -->
<th data-field="status" data-cell-class="badge center">Status</th>
</tr>
</thead>
<tbody></tbody>
</table>Result:
<!-- Header -->
<th class="header-mono">ID</th>
<th class="header-bold">Name</th>
<th>Status</th>
<!-- Body -->
<td>001</td> <!-- no class -->
<td class="text-bold">John Doe</td>
<td class="badge center">Active</td>Dynamic Columns (API Response)
For tables using 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": [...]
}Result:
<!-- 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>Summary
| Type | TH Class | TD Class |
|---|---|---|
| Static HTML | class="..." (standard attribute) |
data-cell-class="..." |
| Dynamic API | "class": "..." (in JSON) |
"cellClass": "..." (in JSON) |
Important Notes:
- ⚠️ No
data-classattribute exists - Completely separate, no fallback between th and td
- Can use either one alone or both together
Table Footer and Aggregates
Recommended approach: declare tfoot explicitly and let footer cells describe their own behavior. This is easier to read than index-based footer generation and works better when you need fixed labels such as Total, custom placement, colspan, or footer-specific classes.
<table data-table="orders"
data-source="/api/orders"
data-show-footer="true">
<thead>
<tr>
<th data-field="id">ID</th>
<th data-field="customer">Customer</th>
<th data-field="status">Status</th>
<th data-field="amount" data-format="number">Amount</th>
<th data-field="created_at" data-format="date">Created</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<th data-field="id" data-aggregate="count" data-format="number" data-cell-class="center"></th>
<th colspan="2" class="right">Total</th>
<th data-field="amount" data-aggregate="sum" data-format="number" data-cell-class="right"></th>
<th data-field="created_at" data-aggregate="count" data-format="number" data-cell-class="center"></th>
</tr>
</tfoot>
</table>Footer behavior
tfootmay use either<th>or<td>.- Static text is preserved. A cell that already contains text such as
Totalis not recalculated. - If a footer cell omits
data-field, TableManager falls back to the corresponding leaf column fromthead. sum,avg,min, andmaxonly run when the source values are fully numeric.- Non-numeric or mixed values are skipped instead of being guessed.
countcounts non-empty values.data-cell-classalso applies to footer output, so footer alignment can differ from the header.colspanandrowspanintfootare respected.
Footer cell attributes
| Attribute | Description |
|---|---|
data-field |
Source field for this footer cell |
data-aggregate |
Aggregate type: sum, avg, count, min, max, or custom |
data-format |
Output format for the aggregate value |
data-formatter |
Custom formatter function name |
data-cell-class |
CSS classes applied to the rendered footer cell |
data-class |
Not supported. Use the normal HTML class attribute instead |
data-prefix |
Text inserted before the formatted value |
data-suffix |
Text inserted after the formatted value |
data-align |
Footer-only text alignment |
data-custom |
Custom aggregate function name when data-aggregate="custom" |
Custom formatter and custom aggregate
Footer cells can use the same formatter style as body cells:
<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 receives (cell, value, rows, attrs). In footer mode, attrs includes isFooter, field, aggregateType, tableId, column, table, and aggregate.
Legacy auto mode
If you do not need explicit footer text or layout, the old mapping style still works:
<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>In this mode, TableManager auto-fills only blank single-column footer cells. Cells that already contain content, or cells covered by colspan, are left untouched.
Column Attributes
| Attribute | Description |
|---|---|
data-field |
Field name |
data-sortable |
Enable sorting |
data-type |
Data type (text, number, date) |
data-format |
Display format |
Configuration
TableManager.init({
debug: false,
urlParams: true,
pageSizes: [10, 25, 50, 100],
showCaption: true,
showCheckbox: false,
allowRowModification: false,
rowSortable: true // Drag handles show when allowRowModification is true
});API Reference
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)
Reload data from source
await TableManager.loadTableData('users-table');TableManager.renderTable(tableId)
Re-render table
TableManager.renderTable('users-table');TableManager.setFilters(tableId, filters)
Set filter values
TableManager.setFilters('users-table', {
name: 'John',
status: 'active'
});TableManager.getSelectedRows(tableId)
Get selected row data
Returns: Array
const selected = TableManager.getSelectedRows('users-table');
console.log('Selected:', selected.length);TableManager.clearSelection(tableId)
Clear row selection
TableManager.enableRowSort(tableId)
Enable drag-drop sorting. Drag handles render when data-row-sortable="true" is set (does not require data-editable-rows).
TableManager.disableRowSort(tableId)
Disable drag-drop sorting and hide drag handles.
TableManager.toggleColumnVisibility(tableId, fieldName, visible)
Show/hide columns dynamically.
Parameters:
tableId(string) - Table identifierfieldName(string) - Field name of the columnvisible(boolean) -true= show,false= hide
Example:
// Hide email column
TableManager.toggleColumnVisibility('users', 'email', false);
// Show email column again
TableManager.toggleColumnVisibility('users', 'email', true);Row Sorting (drag & drop)
- Enable: Add
data-row-sortable="true"to show drag handles (⋮⋮) and enable row reordering. - No need for
data-editable-rows: Both features work independently:data-editable-rows="true"= Enable add/delete row buttonsdata-row-sortable="true"= Enable drag-drop row reordering
- Disable per table: Call
TableManager.disableRowSort(tableId). - UX updates: Small icon ghost follows the cursor, placeholder row uses a dashed outline, and dragging is locked to vertical movement for predictable reordering.
- Event:
table:row-sorted→{tableId, item, oldIndex, newIndex}.
<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')">Disable drag</button>
<button onclick="TableManager.enableRowSort('rows')">Enable drag</button>
</div>TableManager.exportData(tableId, format, options)
Export table data to a file
| Parameter | Type | Description |
|---|---|---|
tableId |
string | Table ID |
format |
string | File format: 'csv', 'json', or 'excel' |
options |
object | Additional options |
| Options: | Option | Type | Description |
|---|---|---|---|
filename |
string | Filename to save |
Notes:
- Export uses filtered/sorted data (what user sees)
- Values are formatted (e.g., shows "Engineering" instead of "1")
- Headers use column names from
<th>instead of field names
// Export to CSV
TableManager.exportData('users-table', 'csv', {
filename: 'employees.csv'
});
// Export to JSON
TableManager.exportData('users-table', 'json', {
filename: 'employees.json'
});Bulk Actions (Checkbox Selection)
Bulk action system for selected rows via checkboxes, with action dropdown and submit button.
Usage
<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 |
Show checkboxes for row selection |
data-actions |
JSON object of actions (key: action value, value: label) |
data-action-url |
URL to send action (POST) |
data-action-button |
Button text, or "label\|className" |
Action Key Format
Simple Action:
{"delete": "Delete", "activate": "Activate"}Sends: {action: "delete", ids: [...]}
Compound Action (using | separator):
{"stage|lead": "Lead", "stage|won": "Won", "stage|lost": "Lost"}Sends: {action: "stage", stage: "lead", ids: [...]}
The action|value format splits the key into:
action= first part (before|)- First part becomes an additional parameter name with value = second part (after
|)
Real-World Example
<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)
Buttons and links that send filter parameters to an action URL - for export, report generation, or bulk operations
Usage
<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 Action Options
| Property | Type | Default | Description |
|---|---|---|---|
label |
string | key name | Text displayed on button/link |
url |
string | required | URL for the action |
type |
string | "button" |
"button" (POST) or "link" (GET redirect) |
className |
string | "" |
Additional CSS classes |
target |
string | "_self" |
For links: "_blank" opens new tab |
confirm |
string | - | Confirmation message before action |
Button Behavior
When clicked, sends a POST request to the URL with data:
{
"action": "export",
"tableId": "my-table",
"filters": {"status": "1", "department": "5"},
"sort": {"name": "asc"}
}Link Behavior
When clicked, redirects to URL with filter params in query string:
/reports?status=1&department=5Event
document.addEventListener('table:filterAction', (e) => {
const {tableId, action, type, params, response} = e.detail;
console.log(`Filter action: ${action} (${type})`);
console.log('Filters:', params);
});Events
| Event | When Triggered | Detail |
|---|---|---|
table:loaded |
Data loaded | {tableId, data} |
table:sorted |
Column sorted | {tableId, field, direction} |
table:filtered |
Filter applied | {tableId, filters} |
table:selected |
Row selection changed | {tableId, selected} |
table:row-sorted |
Row drag-drop | {tableId, item, oldIndex, newIndex} |
table:export |
After export completes | {tableId, format, success, count} |
table:filterAction |
After filter action clicked | {tableId, action, type, params, response} |
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>Real-World Examples
Users Table
<div class="table-container">
<div class="table-toolbar" data-table-toolbar="users">
<input type="search" data-filter="search" placeholder="Search...">
<select data-filter="role">
<option value="">All Roles</option>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
</div>
<table id="users"
data-component="table"
data-source="/api/users"
data-page-size="10"
data-show-checkbox="true">
<thead>
<tr>
<th data-checkbox></th>
<th data-field="name" data-sortable>Name</th>
<th data-field="email" data-sortable>Email</th>
<th data-field="role">Role</th>
<th data-field="created_at" data-sortable data-type="date">Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="table-footer" data-table-pagination="users"></div>
</div>With Actions
// Handle row actions
document.getElementById('users').addEventListener('click', (e) => {
const action = e.target.dataset.action;
const id = e.target.dataset.id;
if (action === 'edit') {
openEditModal(id);
} else if (action === 'delete') {
confirmDelete(id);
}
});
// Bulk delete
document.getElementById('bulk-delete').onclick = async () => {
const selected = TableManager.getSelectedRows('users');
if (confirm(`Delete ${selected.length} items?`)) {
for (const row of selected) {
await ApiService.delete(`/api/users/${row.id}`);
}
TableManager.loadTableData('users');
}
};Server-Side Pagination
<table data-component="table"
data-source="/api/users"
data-server-side="true"
data-page-size="25">
...
</table>API will receive parameters:
GET /api/users?page=1&pageSize=25&sort=name&order=asc&search=johnCSS Styling
/* 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);
}Common Pitfalls
⚠️ 1. Row Data Must Have id Field (Critical!)
For tables with data-editable-rows="true" each row data object must have an id field, otherwise add/delete operations will not work correctly.
// ❌ No id - +/- buttons will malfunction
{
"userstatus": [
{"status": 0, "color": "#000", "topic": "Member"},
{"status": 1, "color": "#333", "topic": "Admin"}
]
}
// ✅ Has id - works correctly
{
"userstatus": [
{"id": 0, "status": 0, "color": "#000", "topic": "Member"},
{"id": 1, "status": 1, "color": "#333", "topic": "Admin"}
]
}Why: TableManager uses id to:
- Track rows in DOM (
tr[data-id="${item.id}"]) - Find rows to copy/delete
- Update the correct row data
Backend Fix:
// Use an existing unique field as id
foreach ($data as $key => $value) {
$result[] = [
'id' => $key, // ← Add this line
'status' => $key,
'name' => $value
];
}⚠️ 2. Element ID Format for Error Highlighting
Elements inside cells use the ID format tableId_field_rowId so APIs can highlight fields with errors:
// ✅ Element ID (predictable)
"userStatus_topic_2" // tableId_field_rowId
// ✅ API Error Response
{
"errors": {
"userStatus_topic_2": "Name is required" // matches element ID
}
}How it works:
- FormManager reads error keys and highlights elements with matching IDs
- Format:
{tableId}_{fieldName}_{rowId} - If API sends a key matching an element ID, that field will show error highlight
⚠️ 3. Caption Auto-Disabled for Editable Tables
Tables with data-editable-rows="true" auto-disable caption:
<!-- ✅ Caption auto-disabled, no need to specify data-show-caption -->
<table data-table="userStatus" data-editable-rows="true">
<!-- ✅ Force caption display (if needed) -->
<table data-table="userStatus" data-editable-rows="true" data-show-caption="true">Why: Editable tables show all data without pagination, so a caption saying "page 1 of 1" is unnecessary.
Note: This warning has been updated since captions are now auto-disabled; no manual configuration needed.
⚠️ 2. Caption for Editable Tables
Tables with data-editable-rows="true" and no pagination should disable caption:
<!-- ✅ Disable caption for non-paginated editable table -->
<table data-table="userStatus"
data-editable-rows="true"
data-show-caption="false">Why: Caption displays text like "All 5 entries, displayed 1 to 5, page 1 of 1 pages" which is inappropriate for editable tables showing all data.
⚠️ 3. Field Names Must Match Data
<!-- ❌ Field doesn't match -->
<th data-field="userName">Name</th>
<!-- data: { name: 'John' } -->
<!-- ✅ Field matches -->
<th data-field="name">Name</th>⚠️ 2. Template in tbody
- ⚠️ Text inputs only honor
maxLengthwhen the value is greater than 0; use positive integers or omit the attribute for unlimited input.
<!-- ❌ No template -->
<tbody>
<tr><td>Static row</td></tr>
</tbody>
<!-- ✅ Has template -->
<tbody>
<template>
<tr><td>{{field}}</td></tr>
</template>
</tbody>Related Documentation
- ApiService - API calls
- Sortable - Drag-drop