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 (column filters)
Put data-filter="true" on a <th> and TableManager builds the filter control
above the table — no filter form needed. data-type picks the control, and the
value is sent to data-source under the column's data-field (a date range
sends two parameters). The page-size selector and the search box are built as
well, from data-page-size and data-search-columns (data-search-placeholder
sets the text of the search box). A parameter that has no column of its own
(a year, a "show" choice, a typed amount) is a column with data-hidden="true":
it builds its filter and shows no cells.
<table data-table="usage" data-source="api/index/usage" data-page-size="25"
data-search-columns="name,topic,reason">
<thead>
<tr>
<th data-field="created_at" data-format="datetime"
data-filter="true" data-type="daterange" data-range-names="from,to"
data-range-presets="today,7d,30d,thismonth,lastmonth" data-label="Date">Created</th>
<th data-field="src" data-filter="true" data-type="select"
data-show-all="true" data-all-value="" data-label="Module">Module</th>
<th data-field="due" data-filter="true" data-type="date" data-label="Due">Due</th>
</tr>
</thead>
<tbody></tbody>
</table>data-type |
Control | Sent to the API |
|---|---|---|
select |
Dropdown. Options from data-options, the response's filters, or the values found in the data |
field=value |
text |
Text box (a client-side table matches "contains") | field=value |
date |
Date picker (a client-side table matches the whole day of a date-time) | field=YYYY-MM-DD |
daterange |
One date picker for a range; either side may stay empty | from=YYYY-MM-DD&to=YYYY-MM-DD |
number · currency |
Number box, sent when committed (Enter or leaving the box). currency takes negative values and 2 decimals |
field=1250.75 |
tabs |
A row of tabs above the other filters, one per option; a click filters at once | field=value |
Attribute on <th> |
Description |
|---|---|
data-filter="true" |
Build a filter for this column |
data-type |
Control type (above) |
data-label |
Label of the control, a translation key (Date or {LNG_Review from} ({LNG_Leave Request})). Default: the key the header text came from (data-i18n), so the label follows the locale when it changes |
data-unit |
Unit after the filter box (days · %), a translation key, shown as a <span class="unit"> inside the box · a column from the API sends unit in columns |
data-placeholder |
Placeholder text |
data-options |
JSON options of a select |
data-show-all · data-all-value · data-all-label |
The "All" option of a select |
data-range-names="from,to" |
The two parameter names of a daterange (default {field}_from, {field}_to) |
data-range-presets |
Preset buttons: today yesterday 7d 30d thismonth lastmonth thisyear |
data-range-max-days |
Longest range allowed, in days |
data-min · data-max |
First / last selectable day (YYYY-MM-DD) of a date filter, or the smallest / largest number of a number filter |
data-step · data-precision |
Decimals of a number filter (data-step="0.01" = 2 decimals), as an editable cell reads them |
data-hidden="true" |
Filter only: the column shows no cells |
Attribute on <table> |
Description |
|---|---|
data-search-placeholder |
Text of the search box, a translation key ({LNG_Name or email}). Default: "Search in: " and the headers of the searched columns the table shows (fields with no column are left out; none at all = "Search...") |
The options of select and tabs come from filters in the response, keyed
by the field: {"filters": {"status": [{"value": "", "text": "Every status"}, ...]}}.
An endpoint that sends its own "all" entry sets data-show-all="false" on the
column so no second one is added. A selected value the server settles on
(a default month, the first account) comes back in meta and is shown in the
filter.
A filter the viewer cannot use comes as false instead of options
({"filters": {"member_id": false}}): its control is hidden, and it comes back
when a later response offers options.
The same options serve two kinds of control, chosen by the column's data-type:
data-type="select"lists them (with "All" as usual).data-type="text"is a box to type in and pick from the list (an input with a datalist: TextElementFactory autocomplete) —
for long lists such as employees: type part of a name and pick; the box showstext, the filter sendsvalue(member_id=8, not the name).- Typing alone does not filter; a pick, or Enter/leaving the box with the text of an option, does. An emptied box clears the filter.
- An empty-value option ("All") is not offered. A value from the address or
metathat names an option shows as its text; one that names none (e.g. 0) is not written into the box. - A text filter without options still filters on the typed text.
<th data-field="member_id" data-filter="true" data-type="text" data-template="${name}">Name</th>
Column groups from data (data-column-group)
A table whose number of columns depends on data (a column per leave type, per
day) writes its prototype columns once in the template; the API sends only the
lists, as data, in columnGroups. All markup stays in the template (unlike
data-dynamic-columns, where the API sends the header).
<thead>
<tr>
<th data-field="name" rowspan="2">Name</th>
<!-- group cell: repeated once per item, spanning its sub-columns -->
<th data-column-group="types" class="center"><span class="one_line" title="{topic}">{topic}</span></th>
</tr>
<tr>
<!-- prototype sub-columns of the group -->
<th data-group-of="types" data-field="limit_{id}" data-i18n>Limit</th>
<th data-group-of="types" data-field="taken_{id}" data-sort="taken_{id}"
data-template="<a href='${taken_url_{id}}'>${taken_{id}}</a>" data-i18n>Taken</th>
</tr>
</thead>{"columnGroups": {"types": [{"id": 1, "topic": "Sick"}, {"id": 2, "topic": "Personal"}]},
"data": [{"name": "...", "limit_1": "30 days", "taken_1": "2 days", "taken_url_1": "/leave-approvals?..."}]}{key}in every attribute and in the text of a prototype becomes the value of that key in the item (escaped in text);{LNG_...}is left alone.- A group cell with no
data-group-ofcells is itself the column (a one-row group, e.g. a column per day):<th data-column-group="days" data-field="d_{key}">{label}</th>. - Several cells of one header row may share a group name: each is repeated per item in its own place, so a group switches a set of columns
on data — a list of one item shows the whole set, an empty list hides it (e.g. the columns of the selected document:pnd1: [{key: 'pnd1'}], sso: []).<th data-column-group="pnd1" data-field="date" data-format="date">Date paid</th> <th data-column-group="pnd1" data-field="tax" class="right">Tax withheld</th> <th data-column-group="sso" data-field="code">Name title code</th> <th data-column-group="sso" data-field="wage" class="right">Wages</th> - The same lists keep the header; a changed list (another year, other types) rebuilds it. Generated columns sort (data-sort) and hold inputs (data-cell-element) like any column.
- Gcms\Table: override
getColumnGroups($params, $login)and return['types' => [...]].
Tabs filter (data-type="tabs")
- An option may carry a count,
{"value": "verify", "text": "Check payment", "count": 3};
a count above zero is a badge beside the text. The counts come with every
response, so they stay current as the list changes. - The tabs are the first row of the filter bar. The active tab is the
current value (from the address, the remembered state ormeta), and a link
with?field=valueopens on that tab.
A table inside a form (inputs in the cells that the form saves): the filter
bar is inside the form too, so its controls are sent with the form under their
names (a year filter gives the save its year). Enter in a filter box filters
and does not submit the form.
Date range filter (data-type="daterange")
- The picker has From / To tabs. The first day picked is the start and the To
side comes next; the second day closes the picker. A day before the start
becomes the start and the old start the end. - One side only: × empties a side. Pick a start and close the picker (Esc or a
click outside) for "from" only; pick the To tab and a day for "to" only. An
empty side is sent as an empty parameter (from=&to=2026-10-16). - The table reloads once per range, when the picker closes — not once per
click. Backspace on the button empties both sides. - Both parameters go to the address bar (
tableId.fromwhen the page has
several tables) and the remembered state, and a link puts them back into the
picker. - The server reads them like any parameter:
$request->get('from')->date(),
$request->get('to')->date(). The end is a whole day, so a date-time column
compares withto.' 23:59:59'.
External Filter Form (data-table-filter)
Use column filters (above) whenever they can express the filter — they cover
select, tabs, date, date range, number, text and search. A separate form is for
a layout they cannot build.
You can build a separate filter form and link it to a table via data-table-filter="tableId". This keeps the form layout flexible 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 · several, comma-separated (#report-top, #report-body), each get the same payload |
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 (with several tables on a page each uses tableId.key) |
| data-remember-state | Remember page size and sort per table, and the filters marked data-remember="true", for a new visit from a menu or an address (default true; moving around inside the app keeps every choice either way; see Remembered State) |
| data-persist-column-widths | Resizable columns whose widths are remembered (default true; see Column Resizing) |
| data-refresh-interval | Seconds between automatic reloads from data-source (0 = off, the default) |
Automatic Refresh
Give a table data-refresh-interval and it reloads itself from data-source on a
timer, keeping the current page, sort and filters.
<table data-table="logLines" data-source="/api/v2/logs" data-refresh-interval="5">The default is 0, so tables without the attribute never poll and behave exactly as
before.
Three behaviours you get for free, because a polling screen that ignores them becomes
a problem within a day:
| Situation | What happens | Why |
|---|---|---|
| Reload is slower than the interval | Next tick is scheduled only after the previous one settles | Chained setTimeout, not setInterval — requests can never stack up |
| Tab is hidden | Polling pauses; on return it reloads once immediately if a tick was missed | A page left open overnight would otherwise fire thousands of requests nobody sees |
| Rows are selected | That tick is skipped | Reloading clears the selection, so refreshing mid-action would throw the user's picks away |
A failed reload does not stop the timer — the endpoint may only be briefly unavailable,
and giving up would leave a frozen screen with no sign that refreshing had stopped.
Change the rate at runtime with setRefreshInterval(),
which is what a "refresh every…" dropdown binds to. Each automatic reload emits
table:refreshed, so a page can show when data last arrived.
Remembered State
Every table remembers what the user chose, with no code.
Moving around inside the app brings every choice back. The table returns exactly
as it was: every filter, with or without data-remember, the search, the page, the
page size and the sort — also with data-remember-state="false". That holds when:
- A form was saved (redirect
back, or a redirect to the path of the list), a row action ran, or a plain link in the page was followed - The browser's back or forward button was pressed
- The page was reloaded (F5, or
location.reload()after a save) - The table was built again on the same page (a component or template rendered again)
A new visit from a menu or an address uses the remember settings. The rules below
apply only when:
- A link in a menu was clicked (inside
data-component="menu",.sidemenuor.topmenu) - An address was opened in the browser (typed, a link from elsewhere, a new tab)
- A link or redirect inside the app names table parameters in its address (
/orders?status=pending): it shows what it names, without the old search
| Kept | Where | Why |
|---|---|---|
| Page size, sort | localStorage |
A choice the user made on purpose; it should hold on the next visit |
A filter whose column has data-remember="true" |
localStorage |
Only where the page needs it (a fiscal year someone works in all day) |
Filters are not remembered by default. A person, a status or a date picked for one
look at a list must not greet the next visit, so a filter starts from its default (or
from meta) unless its column opts in:
<th data-field="year" data-filter="true" data-type="select" data-remember="true" data-hidden="true">Fiscal year</th>A date range filter opts in on its own column (both of its parameters are kept). A value
remembered before a column stopped being remembered is ignored.
The search term and the page are not remembered for a new visit, so a new visit
starts on page one with an empty search box, while the filters marked
data-remember="true" stay. The search stays out of the address, and so does the page
during a search: it is a page of the results. Moving around inside the app keeps both,
and a link that carries ?search= or ?page= still opens with it.
Rules:
- An address with table parameters wins on a new visit (
?page=3,?status=1, a
shared link) and is shown exactly as it is. The remembered state is used only when the
address carries none of the table's parameters — the address is then updated to
show it. - A new visit is told apart from moving around inside the app by the browser (how the
document was opened) and by thesourceof
route:before(a menu sendsmenu). The last state of each
table is kept insessionStorageundernow.table.live:for the tab session. - On back and forward, what the address of that history entry holds wins, and what it
does not hold (the search) comes from the last state of the table. A new visit
replaces that last state, so going back past a menu click brings the filters in the
address back but not the old search. - State is kept per route, per table id and per non-table parameter, so
?module_id=5and?module_id=6are two separate lists. - Only what still fits is restored: a page size the selector offers, filters that
still exist and are still markeddata-remember="true", columns that can still be sorted. A page past the end shows the last
page (server tables are corrected frommeta, client tables locally). - A
data-dynamic-columnstable has no header until the first response, so its
remembered sort is sent as it is (the server's allowed sort columns are the
guard) and checked once the header arrives: a column the API no longer sends is
dropped from the sort, the address and the remembered state. - Turn it off per table with
data-remember-state="false"(moving around inside the
app still keeps every choice); forget it with
clearTableCache(tableId). Removing a table
from the page does not forget anything.
With several tables on one page their address parameters are prefixed with the table
id (?orders.page=2&items.sort=name+asc) so they cannot overwrite each other; a page with
a single table keeps the plain names.
Column Resizing
Every table can have its columns resized, with no code:
- Drag the right border of a header — mouse, touch or pen.
- Focus a border (Tab) and press ← / → (10px, Shift = 50px).
- Double-click a border to return every column to its automatic width.
Widths are stored per column field (not position) and per route + table id, so hiding
or moving a column never hands its width to a neighbour. Widths saved by older releases
(table_<id>_columns) are converted once. Turn resizing off with
data-persist-column-widths="false".
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.- A form beside the table fills its selects from the same response:
<select data-options-key="shift_id">in a target readsoptions.shift_id· a bound table in a target (data-attr="data:options.departments") works as it does in a data-component - 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, its body cells and its cell in the footer the table builds (data-show-footer), so the totals stay under their own headers. toggleColumnVisibility() hides and shows the footer cell along with the column.
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.
Arguments after : (spelled as in data-text)
data-format accepts arguments after the format name, spelled exactly as in a template's data-text / data-format, so one can be copied to the other.
| Written as | Same as |
|---|---|
number:2 |
data-format="number" data-decimals="2" |
percent:1 |
data-format="percent" data-decimals="1" |
currency:THB:th-TH:2 |
data-currency="THB" data-locale="th-TH" data-decimals="2" |
date:D MMM YYYY · datetime:D/M/YYYY HH:mm · time:HH:mm |
data-pattern="..." (the pattern may contain :; everything after the name is one pattern) |
truncate:30 |
Cut at 30 characters (the ... counts towards them) |
An explicit attribute wins over an argument — data-format="number:2" data-decimals="0" shows no decimals. It works in cells, footer aggregates (data-aggregate) and exports alike.
<th data-field="cost" data-format="number:2" class="right" data-cell-class="right">Cost</th>
<th data-field="created_at" data-format="datetime:D MMM YYYY HH:mm">Date</th>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.
An option label carrying{LNG_...}(such as{LNG_Level} 1) is translated in the cell, exactly as the
column's filter select shows it; a plain label, and a row value that matches no option, are shown as written.- 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.
data-template cells and row data
The data-template on a <th> is HTML the developer wrote; ${field} is a row value (server data
that a user may have typed). The two are kept apart from the start:
- Template tokens (
{LNG_...},{{ expr }},data-*directives) are processed from the template
only. Row values are put back into the finished HTML, already escaped — a stored{{7*7}}renders as
{{7*7}}and{LNG_Delete}shows literally, untranslated (they used to be re-processed: template
injection through table data). - A
{html: '...'}value is still inserted as HTML, sanitized as before. {html: '...', i18n: true}— the server says this markup carries{LNG_...}markers to resolve on the
client: TableManager translates them on every render (only the marker; any other brace is left alone), and
since the table re-renders from the raw value on locale change the cell follows the UI language without a
request — unlike markup PHP translated withLanguage::trans(), which stays in the language the server
answered in. Works for adata-template="${field}"column and a plain column alike; no flag = as before.${field|formatter:args}formats the field with the same formatters asdata-text(date,datetime,number,currency...) in the current locale
— the API sends the raw value (2026-09-01) and the template formats it:data-template="${month_date|date:MMMM YYYY}<small>${from|date} – ${to|date}</small>".
An empty/null value renders empty; an unknown formatter shows the raw value; works insidedata-eachtoo.- A
${...}that is not a plain field name (e.g.${id != 1}) passes through verbatim, fordata-if. data-ifinside a cell decides per row —data-if="${can_edit}",data-if="${id != 1}"and
data-if="can_edit"all work (null/empty = hidden); an element that fails is removed from the cell at once.- After rendering, template cells and plain value cells get
translate="no", so I18nManager never
re-translates the data in them (the table re-renders itself on locale change).data-formattercells are
not marked: a formatter owns its cell (Now.translate()for labels,cell.setAttribute('translate', 'no')
around user data).
<th data-field="title" data-template="<a href='/doc?id=${id}'>${title}</a>
<button class='btn' data-if='${can_edit}'>{LNG_Edit}</button>"></th>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/precision (number — precision or decimals sets the decimals that can be typed; without either, a fractional step allows as many as it has, e.g. step="0.5" → 1; a negative min allows typing "-"), datalist, pattern, min-length, max-length (applies only when > 0).
Lists in a cell: data-each="tags" repeats data-template for each item of that array field of the row — ${key} reads the item first, then the row; a list of plain values uses ${item}
(badges of a day, skills of a person) <th data-field="badges" data-each="badges" data-template="<span class='status is-${tag}'>${text}</span>"> · data-if in the template reads row fields.
Disable the control on some rows: data-cell-disabled="field" disables the control of the rows where that field is true; "!field" where it is false
— for a status the viewer may change on their own team's tasks only: the other rows still show their value in a disabled control.
A table without data-editable-rows posts a changed value to data-action-url as {action: 'update', field, ids: [row id], value}.
<table data-table="tasks" data-source="api/onboarding/checklist" data-action-url="api/onboarding/checklist/action">
<thead>
<tr>
<th data-field="name">Task</th>
<th data-field="status" data-cell-element="select" data-options-key="task_options" data-cell-disabled="!can_mark">Status</th>
</tr>
</thead>
<tbody></tbody>
</table>Footer totals: data-show-footer="true" data-footer-aggregates='{"weight":"sum"}' (sum · avg · count · min · max) — an edit in the rows of a table without data-action-url recomputes them at once
(a weight column that must add up to 100); <tfoot> cells that already hold text or span more than one column stay as written.
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, checkbox, switch, etc) |
checkedValue |
string | Value posted by a ticked checkbox/switch cell (default '1') |
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; type is then the filter control (select, tabs, date, daterange, number, text) |
showAll/allValue/allLabel |
The "All" option of a select or tabs filter |
|
rangeNames/rangePresets/rangeMaxDays |
string | As data-range-names, data-range-presets, data-range-max-days of a daterange filter |
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 |
precision |
number | Decimals a number cell accepts (same as decimals for editable cells) |
min/max/step |
number | Limits of a number cell — a negative min allows negative values |
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:
<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.- Before each draw the table puts back the starting markup only of the cells it fills itself (
data-sum/data-aggregate, or a
column indata-footer-aggregates). Any other cell belongs to the template, so totals bound to the page's own data stay through
every draw of the rows (nodata-show-footerneeded):
<form data-load-api="api/shop/order/view">
<table data-table="items" data-attr="data:items" data-url-params="false">
<thead>...</thead>
<tbody></tbody>
<tfoot>
<tr><td colspan="5" data-i18n>Sub total</td><td data-text="subtotal | number:2"></td></tr>
<tr data-if="delivery"><td colspan="5" data-i18n>Shipping fee</td><td data-text="shipping_cost | number:2"></td></tr>
</tfoot>
</table>
</form>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 |
403 / 401 Response Handling
data-table's own data loading (data-source) reacts to 403/401 the same
way ApiComponent does — they
are two separate component systems with two separate request paths, so this
is a genuinely separate implementation, not shared code:
| Status | Default behavior |
|---|---|
403 Forbidden |
RouterManager.navigate() to RouterManager.config.auth.redirects.forbidden, falling back to /forbidden |
401 Unauthorized |
RouterManager.navigate('/login') |
Same rule as ApiComponent: before acting on either, TableManager checks that
the table is still genuinely mounted (TableManager.isAlive(tableId) — still
tracked in state.tables, and its <table> element still attached to
document.body). A data-table embedded inside a data-if-gated container
(an admin-only table on an otherwise shared page) still issues its request
the moment it's created — hiding the wrapper does not stop the request — so
without this check, a table the current user was never shown could still
redirect the entire page over its own moot 403.
Row actions and filter-form submissions (data-row-actions, data-table-filter)
handle 403/401 differently on purpose: those are responses to something the
user explicitly clicked, so they show a notification (403) or route through
AuthErrorHandler (401) instead of navigating away — losing the rest of the
page over one failed button click would be worse than the button just not
working.
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
refreshInterval: 0 // Seconds between automatic reloads, 0 disables it
});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, options?)
Reload data from source
await TableManager.loadTableData('users-table');TableManager.renderTable(tableId)
Re-render table
TableManager.renderTable('users-table');TableManager.setRefreshInterval(tableId, seconds)
Change how often a table reloads itself, or stop it. See
Automatic Refresh.
| Parameter | Type | Description |
|---|---|---|
tableId |
string | Table ID |
seconds |
number | Seconds between reloads, 0 to stop |
Returns: boolean — whether a timer is now running
The value is stored on the table config, so a later startAutoRefresh() keeps it.
// Bind a "refresh every…" dropdown
picker.addEventListener('change', () => {
TableManager.setRefreshInterval('logLines', Number(picker.value));
});TableManager.getRefreshInterval(tableId)
Returns: number — seconds between automatic reloads, 0 when off
if (TableManager.getRefreshInterval('logLines') === 0) {
// refreshing is off
}TableManager.startAutoRefresh(tableId)
Start the timer using the table's current refreshInterval. Called automatically by
initTable(), so you only need it after changing the config by hand. Does nothing
when the interval is 0 or the table has no data-source.
Returns: boolean — whether a timer is now running
TableManager.stopAutoRefresh(tableId)
Stop the timer. Safe to call when none is running; destroyTable() calls it for you.
TableManager.setFilters(tableId, filters)
Set filter values
TableManager.setFilters('users-table', {
name: 'John',
status: 'active'
});TableManager.clearSelection(table, tableId, options?)
Clear row selection
TableManager.enableRowSort(tableId)
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);TableManager.clearTableCache(tableId)
Forgets what a table remembered: its column widths, its remembered page size, sort,
filters and page, and the last state kept for a reload (see
Remembered State). The table keeps its current view; the next
visit starts from the defaults.
// A "reset view" button
TableManager.clearTableCache('users');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.
<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
Emitted through EventManager.emit(), which does not dispatch a DOM event —
listen with EventManager.on(), not addEventListener():
EventManager.on('table:filterAction', (data) => {
const {tableId, action, type, params} = data;
console.log(`Filter action: ${action} (${type})`);
console.log('Filters:', params);
});type is 'link' (payload also carries url) or 'button' (payload also
carries response).
Per-Row Action Conditions
Every entry in data-row-actions may carry a condition — an expression evaluated
against that row's own data, deciding whether the button is rendered for that row.
An action with no condition is always rendered.
<table data-table="sftpAccounts" data-source="/api/v2/sftp/accounts"
data-row-actions='{
"manage": {
"title": "Manage",
"className": "btn btn-success icon-edit",
"method": "get",
"url": "/app/user?id={id}",
"condition": "${can_manage}"
},
"disable": {
"title": "Disable",
"className": "btn btn-danger icon-ban",
"method": "delete",
"url": "/api/v2/users/{id}/sftp",
"condition": "${can_manage && sftp_enabled}",
"confirm": "Disable SFTP for this account?"
}
}'>Field names come from the row, unprefixed. sftp_enabled means this row's
sftp_enabled, not a page-level variable — a row action never sees the page's scope, so
anything it needs to decide on has to be a column the endpoint sends (can_manage above
is exactly that: a per-row copy of the viewer's permission).
${...} is optional, and where you put it does not matter. These three are the same
condition:
"condition": "${can_manage && sftp_enabled}"
"condition": "${can_manage} && ${sftp_enabled}"
"condition": "can_manage && sftp_enabled"A boolean is accepted directly as well ("condition": true), which is useful when the
server composes the action config itself.
Hiding a button is presentation, never protection — the row still carries the data and
the endpoint is still reachable. Check the permission again on the route behind it.
Events
| Event | When Triggered | Detail |
|---|---|---|
table:render |
Table finished rendering | {tableId, data, totalRecords, filteredRecords, isServerSide} |
table:refreshed |
After each auto-refresh cycle | {tableId, automatic, timestamp} |
table:selectionChange |
Row selection changed | {tableId, selectedRows} |
table:fieldChange |
An editable cell changed | {tableId, field, value, rowData, element} |
table:rowAction |
A row action button was pressed | {tableId, action, payload, row} |
table:rowCopied |
A row was duplicated | {tableId, originalItem, newItem} |
table:rowDeleted |
A row was deleted | {tableId, item} |
table:rowRemoved |
A row was removed from the DOM after a bulk action | {tableId, rowId} |
table:action |
An action was sent and answered | {tableId, action, success, response, timestamp} |
table:filterAction |
A filter action button was pressed | {tableId, action, type, url, params} |
table:export |
Export finished | {tableId, format, success} |
table:footerSetup |
The footer was built | {tableId, tfoot, data} |
table:stateUpdate |
State-bound data changed | {tableId, statePath, recordCount, timestamp} |
table:bound |
Table bound to a state path | {tableId, statePath, timestamp} |
table:error |
An action failed | {tableId, action, error, timestamp} |
table:destroyed |
One table was destroyed | {tableId, timestamp} |
tablemanager:destroyed |
destroy() tore down every table | {timestamp} |
EventManager.on('table:loaded', (e) => {
console.log(`Loaded ${e.detail.data.length} rows`);
});Row Template
<table data-component="table" data-source="/api/users">
<thead>
<tr>
<th data-field="name">Name</th>
<th data-field="email">Email</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template>
<tr data-id="{{id}}">
<td>{{name}}</td>
<td>{{email}}</td>
<td>
<button data-action="edit" data-id="{{id}}">Edit</button>
<button data-action="delete" data-id="{{id}}">Delete</button>
</td>
</tr>
</template>
</tbody>
</table>CSS 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 are named {field}[{rowKey}] and get the ID {tableId}_{field}_{rowKey}, so an API can point an error at one cell. In an editable table (data-editable-rows="true") rowKey is a synthetic key such as row_2, which stays the same even when the user edits the row's id column; in other tables it is the row's id, or its position when it has none.
// ✅ Cell of row row_2 in the table "userStatus"
name: "topic[row_2]"
id: "userStatus_topic_row_2" // tableId_field_rowKey
// ✅ API Error Response
{
"errors": {
"userStatus_topic_row_2": "Name is required" // matches the element ID
}
}How it works:
- FormManager reads error keys and highlights elements with matching IDs
- Format:
{tableId}_{fieldName}_{rowKey} - If API sends a key matching an element ID, that field will show error highlight
Which table it is — _editable_tables[]: the POST data carries topic[row_2] but not the table id. So an editable table inside a form adds a hidden field _editable_tables[] holding its table id (one per editable table in the form, removed when the table is destroyed), and the server builds the error key from it. The cell names themselves do not change.
$table = $request->post('_editable_tables', [])->topic()[0] ?? '';
$errors = [];
foreach ($request->post('topic', [])->topic() as $rowKey => $topic) {
if ($topic === '') {
$errors[$table.'_topic_'.$rowKey] = 'Please fill in';
}
}
if (!empty($errors)) {
return $this->formErrorResponse($errors);
}⚠️ 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