Create datatable
Step-by-step: build a server-paginated list grid backed by the v3 getList endpoint.
A settings form holds one row. The moment your module manages many rows — blocked numbers, log entries, queued jobs — you need a list grid: a table that shows a page of rows, lets the admin search and sort, and fetches the next page from the server on demand instead of dumping the whole table into the browser.
This recipe wires a Fomantic-styled DataTables grid to a REST API v3 getList endpoint, the same architecture the core Call Detail Records page uses. We thread it through the running example module ModuleBlackList (model BlackListNumbers, table m_BlackListNumbers, resource slug module-black-list/numbers, JS file module-black-list-index.js).
No EXAMPLES module renders a datatable yet. The canonical Extensions/EXAMPLES/WebInterface/ModuleExampleForm/ module is single-record (one form, one row), and Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv3/ ships the REST layer but no UI grid. This page therefore composes two real, verified sources:
the legacy controller-action grid pattern from the production module
Extensions/ModulePhoneBook/(Volt table markup + DataTables wiring), andthe v3
getListdata source fromExtensions/EXAMPLES/REST-API/ModuleExampleRestAPIv3/Lib/RestAPI/Tasks/Actions/GetListAction.phpplus the production core CDR page that already binds a server-side DataTable to a v3 endpoint (Core/src/AdminCabinet/Controllers/CallDetailRecordsController.php+Core/sites/admin-cabinet/assets/js/src/CallDetailRecords/call-detail-records-index.js).
If you build a grid module, please contribute it back as Extensions/EXAMPLES/WebInterface/ModuleExampleDataTable/ so the next reader has a single working anchor.
This recipe builds on the page scaffolding (controller, providers, asset collections) explained in Module interface and the endpoint explained in REST API in modules. Read those first — here we only cover the grid-specific parts.
Architecture: who serves the rows
A server-paginated grid has two halves that meet over HTTP:
Data source
Lib/RestAPI/Numbers/Actions/GetListAction.php
Reads limit/offset/search/order from the request, queries BlackListNumbers, returns a PBXApiResult with data + pagination.
Grid
App/Views/.../index.volt + public/assets/js/src/module-black-list-index.js
Renders an empty <table> and a DataTable that calls the v3 endpoint, maps DataTables' draw parameters to the v3 query, and renders the returned page.
The controller's indexAction() does almost nothing for a grid — it loads assets and renders a static view. All data flows through the REST endpoint. This is exactly how the core CDR page works: its controller is "a minimal skeleton that only renders the CDR view page" (see the class docblock in CallDetailRecordsController.php).
Step 1 — the data source: GetListAction
The example GetListAction in the reference module queries the database for real, but stays deliberately simple — an optional status filter and ordering, but no pagination:
A grid needs more than that: it must honour limit/offset, optionally filter on search, and report the total row count so the grid can render its pager. Here is the production-grade version for BlackListNumbers:
Wiring the pagination parameters into the Controller
The getList query parameters come from the shared MikoPBX\PBXCoreREST\Lib\Common\CommonDataStructure. Reference them on your controller's getList() method so they are validated and documented:
CommonDataStructure::getPaginationParameters() defines limit (integer, min 1, max 100, default 20) and offset (integer, min 0, default 0). getSearchAndOrderParameters() defines search (string, max 255), order (string — override its enum per resource as above), and orderWay (enum ASC/DESC, default ASC). The reference Tasks controller demonstrates the exact same references — see Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv3/Lib/RestAPI/Tasks/Controller.php, method getList().
Confirm where your params land. The query string is delivered to the action as the $data array ($request['data'] inside Processor::callBack()). Whether a given key reaches $data depends on it being declared via #[ApiParameterRef] on the controller method — undeclared query params may be dropped by sanitization. Declare every parameter your action reads.
Step 2 — the index view: an empty table
The Volt view renders the page chrome (search box, "add" button, page-size selector) and an empty <table> with a <thead> only. DataTables fills the <tbody> from the AJAX response. This markup is adapted directly from the production phonebook grid (Extensions/ModulePhoneBook/App/Views/ModulePhoneBook/Tabs/phonebookTab.volt):
t._('key')translates every label — see Translations.ex_CalculateAutomaticallyandex_ShowOnlyRowsare core translation keys shared by every cabinet grid, so you do not have to define them yourself.The column count in
<thead>must match thecolumnsarray in the JS (Step 4).
Step 3 — register the grid assets in the controller
The index action attaches the DataTables vendor library (CSS + JS) and your compiled grid script, then renders the static view. The vendor asset paths below are the ones the phonebook controller registers (Extensions/ModulePhoneBook/App/Controllers/ModulePhoneBookController.php):
AssetProvider constants (HEADER_CSS, FOOTER_JS, …) and the js/cache/ / css/cache/ compiled-asset convention are explained in Module interface. The core cabinet already loads the JWT helper js/pbx/main/token-manager.js on every page (registered in Core/src/AdminCabinet/Providers/AssetProvider.php), so you do not add it yourself — see the auth note in Step 4.
Step 4 — the grid script: bind DataTables to v3
This is the heart of the recipe. A server-side DataTable (serverSide: true) emits, on every draw, a request describing the page it wants (start, length, search.value, order). You translate those into the v3 query parameters, and translate the PBXApiResult envelope back into what DataTables expects.
The two adapters are ajax.data (out) and ajax.dataSrc (in). This pattern is copied from the production CDR page (Core/sites/admin-cabinet/assets/js/src/CallDetailRecords/call-detail-records-index.js):
How the cabinet authenticates a v3 call from the browser
The getList resource is declared #[ResourceSecurity(..., requirements: [SecurityType::LOCALHOST, SecurityType::BEARER_TOKEN])] (see REST API in modules). A browser is not localhost, so it must send a Bearer token. MikoPBX mints a short-lived JWT for the logged-in admin and exposes it through the global TokenManager object:
Core/sites/admin-cabinet/assets/js/src/main/token-manager.jsdefinesTokenManager(exposed aswindow.TokenManager). It holds the JWT inTokenManager.accessToken(in memory only — neverlocalStorage) and refreshes it silently.On script load it calls
TokenManager.setupGlobalAjax(), which installs a global jQueryajaxSend-style hook that automatically addsAuthorization: Bearer <accessToken>to every jQuery AJAX request that does not already carry the header.
So in practice the beforeSend above is belt-and-suspenders — the core CDR page sets it explicitly, and setupGlobalAjax() would add it anyway. Keep the explicit beforeSend for clarity and to make the dependency obvious.
Do not point the grid at a v3 endpoint declared PUBLIC-only or one without a Bearer requirement just to dodge auth. If the request reaches the server without a valid token it returns 401 and the grid shows an empty table with no obvious cause. The fix is always: ensure SecurityType::BEARER_TOKEN is in the resource's requirements and the page loads token-manager.js (it does, on every cabinet page).
Step 5 — add and delete rows
Delete is shown above: a trash button per row carries
data-value="${data.id}"; clicking it issuesDELETE /pbxcore/api/v3/module-black-list/numbers/{id}, then callsdataTable.ajax.reload(null, false)to refresh the current page without losing scroll position. TheDELETEverb maps to thedeleteoperation in#[HttpMapping], routed by the Processor toDeleteRecordAction.Add can either open a dedicated modify form (shown above —
window.location = .../modify) orPOSTa new row to the collection endpoint (POST /pbxcore/api/v3/module-black-list/numbers) and reload the grid. For the full create/update flow (theSaveRecordActionand its 7-phase pattern) see REST API in modules.
For inline (in-cell) editing — typing directly into a table cell and saving on blur — study the production phonebook grid (Extensions/ModulePhoneBook/public/assets/js/src/module-phonebook-datatable.js): its buildRowTemplate() renders editable inputs and sendChangesToServer() persists each changed row. That module uses a cabinet controller action rather than v3, but the client-side editing mechanics transfer directly.
Step 6 — compile the JavaScript
The browser only ever loads the compiled file from js/cache/<moduleUniqueID>/; the src/ file is never served directly. That URL prefix is not a build output directory — the installer symlinks js/cache/<moduleUniqueID> straight to your module's public/assets/js (PbxExtensionUtils::createAssetsSymlinks(), Core/src/Modules/PbxExtensionUtils.php:105-114; css and img are symlinked the same way). So Babel's --out-dir is public/assets/js, the parent of src/:
If your code edit does not appear in the running cabinet, you almost always forgot this step (or need to clear the browser cache). The compiled path is what the controller registers in Step 3.
Checklist
Data source —
GetListActionreadslimit/offset/search/orderfrom$data, sets$result->data(the page) and$result->pagination(total,limit,offset,hasMore).Controller (REST) —
getList()references the pagination/search params via#[ApiParameterRef(..., dataStructure: CommonDataStructure::class)].Controller (cabinet) —
indexAction()attaches the DataTables vendor assets + your compiled grid script and picks the static index view.View — an empty
<table>with a<thead>whose column count matches the JScolumnsarray.Grid JS —
serverSide: true;ajax.datamaps DataTables → v3 query;ajax.dataSrcreadsjson.result/json.pagination.total/json.dataand setsrecordsTotal/recordsFiltered.Auth — the page carries
token-manager.js(every cabinet page does), andTokenManager.accessTokenis sent asAuthorization: Bearer ….Compile the JS into
public/assets/js/(whichjs/cache/<id>symlinks to) before testing.
See also
Forms overview — the three form recipes and how they relate.
Create a module form — the single-record settings form (the "modify" page the Add button links to).
REST API in modules — the full v3 endpoint pattern: Controller attributes, Processor, Actions,
DataStructure, and the 7-phase save flow.Module interface — the controller, view, provider and asset-collection scaffolding this recipe builds on.
Real anchors to read:
Core/src/AdminCabinet/Controllers/CallDetailRecordsController.phpandCore/sites/admin-cabinet/assets/js/src/CallDetailRecords/call-detail-records-index.js— a production server-side DataTable bound to a v3 endpoint.Extensions/ModulePhoneBook/— a production grid module (controller-action data source, inline editing).Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv3/Lib/RestAPI/Tasks/— the v3 endpoint skeleton (Controller,Processor,GetListAction).
Last updated
Was this helpful?