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).
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().
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.
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/:
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?