Now.js Framework Documentation

Now.js Framework Documentation

TableManager

EN 09 May 2026 02:50

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 (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-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-target runs after data normalization, so the target can use data, 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, and data-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:

  • lookup formatting still resolves options from API response options, filter options, or data-options.
  • Non-lookup built-in formats read column attributes directly, so decimals, currency, locale, and pattern can be declared per column.
  • Use formatter when 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-class attribute exists
  • Completely separate, no fallback between th and td
  • Can use either one alone or both together

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>
  • tfoot may use either <th> or <td>.
  • Static text is preserved. A cell that already contains text such as Total is not recalculated.
  • If a footer cell omits data-field, TableManager falls back to the corresponding leaf column from thead.
  • sum, avg, min, and max only run when the source values are fully numeric.
  • Non-numeric or mixed values are skipped instead of being guessed.
  • count counts non-empty values.
  • data-cell-class also applies to footer output, so footer alignment can differ from the header.
  • colspan and rowspan in tfoot are respected.
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 identifier
  • fieldName (string) - Field name of the column
  • visible (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 buttons
    • data-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"}
}

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>