For the complete documentation index, see llms.txt. This page is also available as Markdown.

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).

Assets are named per action, not per module: the grid page (indexAction) loads module-black-list-index.js / module-black-list-index.css, while the edit page (modifyAction) loads module-black-list-modify.js. The production grid module follows the same rule — see the file list under Extensions/ModulePhoneBook/public/assets/js/src/ (module-phonebook-index.js, module-phonebook-settings.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:

Half
Lives in
Responsibility

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).

Why v3 and not a controller AJAX action? The older grids (e.g. ModulePhoneBook::getNewRecordsAction()) returned DataTables-native JSON from a cabinet controller action. That still works, but a v3 getList endpoint gives you one data source that serves both the grid and external API clients, with OpenAPI docs, RBAC, and the standard PBXApiResult envelope for free. The core CDR page was migrated from the controller-action style to v3 for exactly this reason.

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:

The pagination field is a first-class property of PBXApiResult (Core/src/PBXCoreREST/Lib/PBXApiResult.php): public ?array $pagination = null. Its documented keys are total, limit, offset, hasMore, lastId. When set, getResult() emits it as a top-level pagination object in the JSON envelope.

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_CalculateAutomatically and ex_ShowOnlyRows are core translation keys shared by every cabinet grid, so you do not have to define them yourself.

  • The column count in <thead> must match the columns array 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.js defines TokenManager (exposed as window.TokenManager). It holds the JWT in TokenManager.accessToken (in memory only — never localStorage) and refreshes it silently.

  • On script load it calls TokenManager.setupGlobalAjax(), which installs a global jQuery ajaxSend-style hook that automatically adds Authorization: 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.

Envelope shape: flat vs nested. The example above returns a flat data array and a top-level pagination object — the natural output of PBXApiResult when you set $result->data and $result->pagination. The production CDR action wraps its payload one level deeper — data: { records: [...], pagination: {...} } — and its dataSrc reads json.data.records / json.data.pagination accordingly (see Core/src/PBXCoreREST/Lib/Cdr/GetListAction.php). Both are valid; just keep your action and your dataSrc in agreement.

Step 5 — add and delete rows

  • Delete is shown above: a trash button per row carries data-value="${data.id}"; clicking it issues DELETE /pbxcore/api/v3/module-black-list/numbers/{id}, then calls dataTable.ajax.reload(null, false) to refresh the current page without losing scroll position. The DELETE verb maps to the delete operation in #[HttpMapping], routed by the Processor to DeleteRecordAction.

  • Add can either open a dedicated modify form (shown above — window.location = .../modify) or POST a new row to the collection endpoint (POST /pbxcore/api/v3/module-black-list/numbers) and reload the grid. For the full create/update flow (the SaveRecordAction and 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

  1. Data sourceGetListAction reads limit/offset/search/order from $data, sets $result->data (the page) and $result->pagination (total, limit, offset, hasMore).

  2. Controller (REST)getList() references the pagination/search params via #[ApiParameterRef(..., dataStructure: CommonDataStructure::class)].

  3. Controller (cabinet)indexAction() attaches the DataTables vendor assets + your compiled grid script and picks the static index view.

  4. View — an empty <table> with a <thead> whose column count matches the JS columns array.

  5. Grid JSserverSide: true; ajax.data maps DataTables → v3 query; ajax.dataSrc reads json.result/json.pagination.total/json.data and sets recordsTotal/recordsFiltered.

  6. Auth — the page carries token-manager.js (every cabinet page does), and TokenManager.accessToken is sent as Authorization: Bearer ….

  7. Compile the JS into public/assets/js/ (which js/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.php and Core/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?