Now.js Framework Documentation

Now.js Framework Documentation

TableManager

EN 30 Dec 2025 05:05

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 (table data-table must 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 matching name are populated automatically.

API payload example

GET /api/index/usage?page=1&pageSize=25&from=2024-01-01&to=2024-12-31&search=test

Notes

  • 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-class attribute 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 of allowRowModification: true) to show row controls; drag handles appear when rowSortable is not false.
  • Disable per table: data-row-sortable="false" or 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-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"}
}

When clicked, redirects to URL with filter params in query string:

/reports?status=1&department=5

Event

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=john

CSS 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 maxLength when 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>