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="create_date 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="create_date" data-sort="create_date" 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-page-size |
Items per page |
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/drag) |
data-row-sortable |
Enable/disable row drag-drop (default: true when editing rows) |
data-sortable-rows |
Enable drag-drop reorder |
data-url-params |
Persist state in URL |
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 |
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 |
Data Binding (data-attr)
Use data-attr to bind data from nested objects in API response:
Format: data-attr="data:path.to.data"
Examples:
<!-- Simple binding -->
<table data-attr="data:users"></table>
<!-- API: {"users": [...]} -->
<!-- Nested binding -->
<table data-attr="data:translate"></table>
<!-- API: {"translate": {"columns": [...], "data": [...]}} -->
<!-- Deep nested -->
<table data-attr="data:report.sales.monthly"></table>
<!-- API: {"report": {"sales": {"monthly": [...]}}} -->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
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 only render when allowRowModification is true. Default is enabled unless rowSortable is set to false via config or data-row-sortable="false".
TableManager.disableRowSort(tableId)
Disable drag-drop sorting and hide drag handles.
Row Sorting (drag & drop)
- Opt-in: Add
data-editable-rows="true"(alias ofallowRowModification: true) to show row controls; drag handles appear whenrowSortableis notfalse. - Disable per table:
data-row-sortable="false"or callTableManager.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-editable-rows="true"
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