Skip to content

Instantly share code, notes, and snippets.

@nfaiz
Last active October 9, 2025 06:14
Show Gist options
  • Select an option

  • Save nfaiz/099a51c2fc17985a0f23807e233e2718 to your computer and use it in GitHub Desktop.

Select an option

Save nfaiz/099a51c2fc17985a0f23807e233e2718 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description" content="" />
<meta name="author" content="" />
<title><?= config('MyConfig')->siteTitle; ?>::<?= esc($pageTitle ?? 'List') ?></title>
<!-- Favicon-->
<link rel="icon" type="image/x-icon" href="assets/favicon.ico" />
<!-- Core theme CSS (includes Bootstrap)-->
<link href="<?= base_url('assets/css/styles.css') ?>" rel="stylesheet" />
<?= $this->renderSection('source_header') ?>
<?= $this->renderSection('script_header') ?>
</head>
<body>
<div class="d-flex" id="wrapper">
<?php //= $this->include('template/sidebar') ?>
<?= view_cell('BootstrapCell::sidebar') ?>
<!-- Page content wrapper-->
<div id="page-content-wrapper">
<!-- Top navigation-->
<?= $this->include('template/navbar') ?>
<!-- Page content-->
<div class="container-fluid">
<p>
<?= $this->renderSection('content') ?>
</p>
</div>
</div>
</div>
<!-- Bootstrap core JS-->
<script src="<?= base_url('assets/js/bootstrap.bundle.min.js') ?>"></script>
<script src="<?= base_url('assets/js/scripts.js') ?>"></script>
<?= $this->renderSection('source_footer') ?>
<?= $this->renderSection('script_footer') ?>
<!-- Core theme JS-->
</body>
</html>
<?php
namespace App\Controllers\Report;
use App\Controllers\BaseController;
use App\Libraries\RowFormatter;
use CodeIgniter\HTTP\IncomingRequest;
class CustomerController extends BaseController
{
use \App\Traits\DatatableTrait;
public function index()
{
// ordered columns map key => type
$columns = [
'id' => 'number',
'name' => 'text',
'phone' => 'text',
'address' => 'text',
'state_name' => 'text',
'customer_created_at' => 'datetime',
'actions' => 'actions'
];
// Which DataTables Buttons to enable (subset of ['copy','csv','excel','print'])
// Controller can change this per page; passed to view as array
$buttons = ['copy','csv','excel','print'];
return view('report/customer/index', [
'pageTitle' => 'Customers',
'columns' => $columns,
'ajaxUrlBuilder' => site_url('report/customer/ajaxBuilder'),
'ajaxUrlRaw' => site_url('report/customer/ajaxRaw'),
'ajaxUrlDelete' => site_url('report/customer/delete'),
'ajaxUrlView' => site_url('customers/show'), // example
'ajaxUrlEdit' => site_url('customers/edit'), // example
'dtButtons' => $buttons
]);
}
public function ajaxBuilder()
{
/** @var IncomingRequest $request */
$request = service('request');
$db = db_connect();
// Build BaseBuilder
$builder = $db->table('customers AS customers')
->select("
customers.id,
customers.name,
customers.phone,
customers.address,
states.name,
customers.created_at
")
->join('states', 'states.id = customers.state_id', 'left');
//$builder->like('states.name', 'Joh', 'after');
//$builder->where('customers.id >', 1);
$columnsConfig = [
['data' => 'id', 'db' => 'customers.id', 'type' => 'number'],
['data' => 'name', 'db' => 'customers.name', 'type' => 'text'],
['data' => 'phone', 'db' => 'customers.phone', 'type' => 'text'],
['data' => 'address', 'db' => 'customers.address', 'type' => 'text'],
['data' => 'state_name', 'db' => 'states.name', 'type' => 'text'],
['data' => 'customer_created_at', 'db' => 'customers.created_at', 'type' => 'datetime'],
['data' => 'actions', 'db' => "' ' AS actions", 'type' => 'actions'],
];
$rules = [
'customer_created_at' => 'date:d M Y H:i',
'phone' => 'phone_mask',
'actions' => [
[
'label' => '<i class="bi bi-eye"></i> View',
'url' => site_url('customer/show/{id}'),
'class' => 'btn btn-sm btn-primary',
'attrs' => [],
'escape_label' => false
],
[
'label' => '<i class="bi bi-pencil-square"></i> Edit',
'url' => site_url('customer/edit/{id}'),
'class' => 'btn btn-sm btn-secondary',
'attrs' => [],
'escape_label' => false
],
[
'label' => '<i class="bi bi-trash"></i> Delete',
'url' => '#',
'class' => 'btn btn-sm btn-danger',
'attrs' => ['data-action' => 'ajax-delete', 'data-id' => '{id}', 'data-name' => '{name}'],
'escape_label' => false
],
],
'actions_wrapper' => 'dropdown'
];
$formatter = new RowFormatter($columnsConfig, $rules);
$options = [
'onRow' => $formatter
];
$result = $this->datatableReport($request, $builder, $columnsConfig, $options);
return $this->response->setJSON($result);
}
public function ajaxRaw()
{
$request = service('request');
$sql = "
SELECT
customers.id,
customers.name,
customers.phone,
customers.address,
states.name,
customers.created_at
FROM customers
LEFT JOIN states ON states.id = customers.state_id
";
//WHERE states.name like ? AND customers.id > ?
//$bindings = ['Joh%', 10];
$bindings = [];
$columnsConfig = [
['data' => 'id', 'db' => 'customers.id', 'type' => 'number'],
['data' => 'name', 'db' => 'customers.name', 'type' => 'text'],
['data' => 'phone', 'db' => 'customers.phone', 'type' => 'text'],
['data' => 'address', 'db' => 'customers.address', 'type' => 'text'],
['data' => 'state_name', 'db' => 'states.name', 'type' => 'text'],
['data' => 'customer_created_at', 'db' => 'customers.created_at', 'type' => 'datetime'],
['data' => 'actions', 'db' => "' ' AS actions", 'type' => 'actions'],
];
$rules = [
'customer_created_at' => 'date:d M Y H:i',
'phone' => 'phone_mask',
'actions' => 'default',
'actions_default' => [
[
'label' => '<i class="bi bi-eye"></i> View',
'url' => site_url('customers/show/{id}'),
'class' => 'btn btn-sm btn-primary',
'attrs' => [],
'escape_label' => false
],
[
'label' => '<i class="bi bi-pencil-square"></i> Edit',
'url' => site_url('customers/edit/{id}'),
'class' => 'btn btn-sm btn-secondary',
'attrs' => [],
'escape_label' => false
],
[
'label' => '<i class="bi bi-trash"></i> Delete',
'url' => '#',
'class' => 'btn btn-sm btn-danger',
'attrs' => ['data-action' => 'ajax-delete', 'data-id' => '{id}', 'data-name' => '{name}'],
'escape_label' => false
],
],
'actions_wrapper' => 'group'
];
$formatter = new RowFormatter($columnsConfig, $rules);
$options = [
'bindings' => $bindings,
'onRow' => $formatter
];
$result = $this->datatableReport($request, $sql, $columnsConfig, $options);
return $this->response->setJSON($result);
}
public function delete()
{
$request = service('request');
$id = $request->getPost('id');
if (empty($id) || !is_numeric($id)) {
return $this->response->setJSON(['success' => false, 'message' => 'Invalid id']);
}
// TODO: enforce authorization & CSRF (CI4 can enforce CSRF globally)
$db = db_connect();
$builder = $db->table('customers');
$exists = (bool) $builder->where('id', (int)$id)->countAllResults(false);
if (! $exists) {
return $this->response->setJSON(['success' => false, 'message' => 'Not found']);
}
$deleted = $builder->where('id', (int)$id)->delete();
if ($deleted) {
return $this->response->setJSON(['success' => true, 'message' => 'Deleted']);
}
return $this->response->setJSON(['success' => false, 'message' => 'Delete failed']);
}
}
<?php
namespace App\Traits;
use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\HTTP\IncomingRequest;
use Config\Database;
trait DatatableTrait
{
public function datatableReport(IncomingRequest $request, $queryOrRaw, array $columnsConfig, array $options = []): array
{
$db = Database::connect();
// DataTables params
$draw = (int) ($request->getPost('draw') ?? 0);
$start = (int) ($request->getPost('start') ?? 0);
$length = (int) ($request->getPost('length') ?? 10);
$globalSearch = (string) ($request->getPost('search')['value'] ?? '');
$orderArr = $request->getPost('order') ?? [];
$columnsPosted = $request->getPost('columns') ?? [];
if (empty($columnsConfig) || !is_array($columnsConfig)) {
throw new \InvalidArgumentException('columnsConfig required');
}
// Build helper maps
$dataKeys = array_map(fn($c) => $c['data'], $columnsConfig);
$dataToDb = [];
$typeMap = [];
foreach ($columnsConfig as $c) {
$k = $c['data'];
$dataToDb[$k] = $c['db'] ?? $k;
$typeMap[$k] = $c['type'] ?? 'text';
}
$searchable = $options['search_columns'] ?? array_values(array_filter($dataKeys, fn($k) => in_array($typeMap[$k], ['text','raw','date','datetime','number','float','currency'])));
$orderable = $options['orderable'] ?? $dataKeys;
$defaultOrder = $options['default_order'] ?? null;
$onRowSpec = $options['onRow'] ?? null;
$onRowCallable = $this->resolveOnRowCallable($onRowSpec);
// If Query Builder is provided, use builder flow (no getBindings)
if ($queryOrRaw instanceof BaseBuilder) {
/** @var BaseBuilder $builder */
$builder = $queryOrRaw;
// recordsTotal: by default count on the builder as-is (controller may pass original_builder in options to override)
if (!empty($options['original_builder']) && $options['original_builder'] instanceof BaseBuilder) {
$totalBuilder = clone $options['original_builder'];
// Ensure no limit/order when counting
$recordsTotal = (int) $totalBuilder->countAllResults(false);
} else {
$totalBuilder = clone $builder;
$recordsTotal = (int) $totalBuilder->countAllResults(false);
}
// Apply search: per-column and global (builder->like / orLike)
if (!empty($columnsPosted)) {
// per-column search
foreach ($columnsPosted as $idx => $colPost) {
$colSearch = $colPost['search']['value'] ?? '';
if ($colSearch === '') continue;
$dataKey = $dataKeys[$idx] ?? ($colPost['data'] ?? null);
if (!$dataKey) continue;
if (!in_array($dataKey, $searchable, true)) continue;
$dbExpr = $dataToDb[$dataKey] ?? $dataKey;
// Use where with LIKE via builder; use escapeLikeString to be safe
$builder->groupStart();
$builder->like($dbExpr, $colSearch);
$builder->groupEnd();
}
}
if ($globalSearch !== '' && !empty($searchable)) {
$builder->groupStart();
$first = true;
foreach ($searchable as $dataKey) {
$dbExpr = $dataToDb[$dataKey] ?? $dataKey;
if ($first) {
$builder->like($dbExpr, $globalSearch);
$first = false;
} else {
$builder->orLike($dbExpr, $globalSearch);
}
}
$builder->groupEnd();
}
// recordsFiltered: count with the builder that now includes search filters
$filteredBuilder = clone $builder;
$recordsFiltered = (int) $filteredBuilder->countAllResults(false);
// Ordering: map posted order to builder->orderBy
if (!empty($orderArr)) {
foreach ($orderArr as $ord) {
$colIndex = (int) ($ord['column'] ?? 0);
$dir = strtoupper($ord['dir'] ?? 'ASC') === 'DESC' ? 'DESC' : 'ASC';
$dataKey = $dataKeys[$colIndex] ?? null;
if (!$dataKey) continue;
if (!in_array($dataKey, $orderable, true)) continue;
$dbExpr = $dataToDb[$dataKey] ?? $dataKey;
// If dbExpr contains AS alias, try to order by alias
if (preg_match('/\s+AS\s+([`"]?)([a-zA-Z0-9_]+)\1$/i', $dbExpr, $m)) {
$orderCol = $m[2];
} else {
$orderCol = $dbExpr;
}
$builder->orderBy($orderCol, $dir);
}
} elseif (!empty($defaultOrder['db'])) {
$builder->orderBy($defaultOrder['db'], $defaultOrder['dir'] ?? 'DESC');
}
// Limit / Offset
if ($length != -1) {
$builder->limit(intval($length), intval($start));
}
// Execute and fetch rows
$query = $builder->get();
$rows = $query->getResultArray();
// Map and onRow
$outData = [];
foreach ($rows as $rawRow) {
$outRow = [];
foreach ($columnsConfig as $colDef) {
$key = $colDef['data'];
if (array_key_exists($key, $rawRow)) {
$outRow[$key] = $rawRow[$key];
continue;
}
// fallback alias resolution
$dbExpr = $colDef['db'] ?? $key;
$alias = null;
if (preg_match('/\s+AS\s+([`"]?)([a-zA-Z0-9_]+)\1$/i', $dbExpr, $m)) {
$alias = $m[2];
} else {
$parts = preg_split('/\s+/', $dbExpr);
$last = end($parts);
if (strpos($last, '.') !== false) {
$p = explode('.', $last);
$alias = end($p);
} else {
$alias = $last;
}
}
$alt = str_replace('.', '_', $alias);
if (array_key_exists($alias, $rawRow)) {
$outRow[$key] = $rawRow[$alias];
} elseif (array_key_exists($alt, $rawRow)) {
$outRow[$key] = $rawRow[$alt];
} else {
$outRow[$key] = null;
}
}
if (is_callable($onRowCallable)) {
$res = $onRowCallable($outRow, $rawRow);
if (is_array($res)) $outRow = $res;
}
$outData[] = $outRow;
}
return [
'draw' => $draw,
'recordsTotal' => $recordsTotal,
'recordsFiltered' => $recordsFiltered,
'data' => $outData,
];
}
// ---------- RAW SQL FLOW ----------
// $queryOrRaw expected to be raw SQL string (no trailing ;) and options['bindings'] has values
$rawSql = (string) $queryOrRaw;
$bindings = $options['bindings'] ?? [];
// Build WHERE parts: per-column and global search (use ? placeholders)
$whereParts = [];
$whereBindings = [];
foreach ($columnsPosted as $idx => $colPost) {
$colSearch = $colPost['search']['value'] ?? '';
if ($colSearch === '') continue;
$dataKey = $dataKeys[$idx] ?? ($colPost['data'] ?? null);
if (!$dataKey) continue;
if (!in_array($dataKey, $searchable, true)) continue;
$dbExpr = $dataToDb[$dataKey] ?? $dataKey;
$whereParts[] = "{$dbExpr} LIKE ?";
$whereBindings[] = '%' . $db->escapeLikeString($colSearch) . '%';
}
if ($globalSearch !== '') {
$gsParts = [];
foreach ($searchable as $dataKey) {
$dbExpr = $dataToDb[$dataKey] ?? $dataKey;
$gsParts[] = "{$dbExpr} LIKE ?";
$whereBindings[] = '%' . $db->escapeLikeString($globalSearch) . '%';
}
if (!empty($gsParts)) $whereParts[] = '(' . implode(' OR ', $gsParts) . ')';
}
if (!empty($whereParts)) {
$whereSql = implode(' AND ', $whereParts);
if (stripos($rawSql, ' where ') !== false) {
$rawSql .= ' AND ' . $whereSql;
} else {
$rawSql .= ' WHERE ' . $whereSql;
}
$bindings = array_merge($bindings, $whereBindings);
}
// recordsTotal
if (!empty($options['count_sql'])) {
$countTotalSql = $options['count_sql'];
$recordsTotal = (int) ($db->query($countTotalSql, $options['bindings'] ?? [])->getRow()->cnt ?? 0);
} else {
$countTotalSql = "SELECT COUNT(*) AS cnt FROM ({$queryOrRaw}) AS dt_total";
$recordsTotal = (int) ($db->query($countTotalSql, $options['bindings'] ?? [])->getRow()->cnt ?? 0);
}
// recordsFiltered
$countFilteredSql = "SELECT COUNT(*) AS cnt FROM ({$rawSql}) AS dt_filtered";
$recordsFiltered = (int) ($db->query($countFilteredSql, $bindings)->getRow()->cnt ?? 0);
// Ordering
$orderSqlParts = [];
foreach ($orderArr as $ord) {
$colIndex = (int) ($ord['column'] ?? 0);
$dir = strtoupper($ord['dir'] ?? 'ASC') === 'DESC' ? 'DESC' : 'ASC';
$dataKey = $dataKeys[$colIndex] ?? null;
if (!$dataKey) continue;
$dbExpr = $dataToDb[$dataKey] ?? $dataKey;
if (preg_match('/\s+AS\s+([`"]?)([a-zA-Z0-9_]+)\1$/i', $dbExpr, $m)) {
$orderCol = $m[2];
} else {
if (strpos($dbExpr, '.') !== false) {
$parts = explode('.', $dbExpr);
$orderCol = end($parts);
} else {
$orderCol = $dbExpr;
}
}
if (in_array($dataKey, $orderable, true) || in_array($orderCol, $orderable, true)) {
$orderSqlParts[] = "{$orderCol} {$dir}";
}
}
if (empty($orderSqlParts) && !empty($defaultOrder['db'])) {
$orderSqlParts[] = ($defaultOrder['db'] . ' ' . ($defaultOrder['dir'] ?? 'DESC'));
}
if (!empty($orderSqlParts)) $rawSql .= ' ORDER BY ' . implode(', ', $orderSqlParts);
if ($length != -1) {
$rawSql .= ' LIMIT ' . intval($length) . ' OFFSET ' . intval($start);
}
$rows = $db->query($rawSql, $bindings)->getResultArray();
$outData = [];
foreach ($rows as $rawRow) {
$outRow = [];
foreach ($columnsConfig as $colDef) {
$key = $colDef['data'];
if (array_key_exists($key, $rawRow)) {
$outRow[$key] = $rawRow[$key];
continue;
}
$dbExpr = $colDef['db'] ?? $key;
$alias = null;
if (preg_match('/\s+AS\s+([`"]?)([a-zA-Z0-9_]+)\1$/i', $dbExpr, $m)) {
$alias = $m[2];
} else {
$parts = preg_split('/\s+/', $dbExpr);
$last = end($parts);
if (strpos($last, '.') !== false) {
$p = explode('.', $last);
$alias = end($p);
} else {
$alias = $last;
}
}
$alt = str_replace('.', '_', $alias);
if (array_key_exists($alias, $rawRow)) {
$outRow[$key] = $rawRow[$alias];
} elseif (array_key_exists($alt, $rawRow)) {
$outRow[$key] = $rawRow[$alt];
} else {
$outRow[$key] = null;
}
}
if (is_callable($onRowCallable)) {
$res = $onRowCallable($outRow, $rawRow);
if (is_array($res)) $outRow = $res;
}
$outData[] = $outRow;
}
return [
'draw' => $draw,
'recordsTotal' => $recordsTotal,
'recordsFiltered' => $recordsFiltered,
'data' => $outData,
];
}
protected function resolveOnRowCallable($spec): ?callable
{
if (empty($spec)) return null;
if (is_callable($spec)) return $spec;
if (is_object($spec)) {
if (method_exists($spec, 'handle')) {
return function($out, $raw) use ($spec) { return $spec->handle($out, $raw); };
}
return null;
}
if (is_array($spec) && isset($spec['class'])) {
$class = $spec['class'];
$method = $spec['method'] ?? 'handle';
return function($out, $raw) use ($class, $method) {
$inst = new $class();
return $inst->{$method}($out, $raw);
};
}
if (is_string($spec) && class_exists($spec)) {
return function($out, $raw) use ($spec) {
$inst = new $spec();
if (method_exists($inst, 'handle')) {
return $inst->handle($out, $raw);
}
return $out;
};
}
return null;
}
}
<?= $this->extend('template/bootstrap2') ?>
<?php
/** Generic DataTable view
* Expects:
* - $pageTitle
* - $columns (ordered associative key => type)
* - $ajaxUrlBuilder, $ajaxUrlRaw, $ajaxUrlDelete
* - $dtButtons (array of button names e.g. ['copy','csv','excel','print'])
*/
?>
<?= $this->section('source_header') ?>
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap5.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/buttons/2.4.1/css/buttons.bootstrap5.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css">
<?= $this->endSection() ?>
<?= $this->section('script_header') ?>
<style>.dt-actions { white-space: nowrap; }</style>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<h3><?= esc($pageTitle ?? 'List') ?></h3>
<div class="mb-3">
<button id="btn-builder" class="btn btn-sm btn-primary">Builder Mode</button>
<button id="btn-raw" class="btn btn-sm btn-secondary">Raw Mode</button>
</div>
<table id="list" class="table table-striped" style="width:100%">
<thead>
<tr>
<?php if (empty($columns) || !is_array($columns)): ?>
<th>ID</th><th>Name</th><th>Actions</th>
<?php else: foreach ($columns as $k => $t): ?>
<th><?= esc(ucwords(str_replace(['_','.'], ' ', $k))) ?></th>
<?php endforeach; endif; ?>
</tr>
</thead>
<tbody></tbody>
</table>
<?= $this->endSection() ?>
<?= $this->section('source_footer') ?>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script>
<!-- Buttons extension -->
<script src="https://cdn.datatables.net/buttons/2.4.1/js/dataTables.buttons.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.1/js/buttons.bootstrap5.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.1/js/buttons.html5.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.1/js/buttons.print.min.js"></script>
<?= $this->endSection() ?>
<?= $this->section('script_footer') ?>
<script>
(function(){
const csrfName = $('meta[name="csrf-name"]').attr('content');
const csrfHash = $('meta[name="csrf-hash"]').attr('content');
const urlBuilder = <?= json_encode($ajaxUrlBuilder) ?>;
const urlRaw = <?= json_encode($ajaxUrlRaw) ?>;
const urlDelete = <?= json_encode($ajaxUrlDelete) ?>;
const dtButtons = <?= json_encode($dtButtons ?? ['copy','csv','excel','print']) ?>;
const columnKeys = <?= json_encode(array_keys($columns)) ?>;
const dtColumns = columnKeys.map(k => {
if (k === 'actions') return { data: k, name: k, orderable: false, searchable: false, className: 'dt-actions' };
return { data: k, name: k, orderable: true, searchable: true };
});
let table;
function initTable(mode) {
const ajaxUrl = mode === 'raw' ? urlRaw : urlBuilder;
if ($.fn.dataTable.isDataTable('#list')) {
$('#list').DataTable().destroy();
$('#list tbody').empty();
}
// Build buttons array for DataTables
const buttons = dtButtons.map(b => {
if (b === 'excel') return { extend: 'excelHtml5', text: 'Excel' };
if (b === 'csv') return { extend: 'csvHtml5', text: 'CSV' };
if (b === 'print') return { extend: 'print', text: 'Print' };
if (b === 'copy') return { extend: 'copy', text: 'Copy' };
return null;
}).filter(x => x !== null);
table = $('#list').DataTable({
processing: true,
serverSide: true,
ajax: {
url: ajaxUrl,
type: 'POST',
data: function(d) {
d.mode = mode;
d[csrfName] = csrfHash;
},
dataSrc: function(json) { return json.data || []; }
},
columns: dtColumns,
order: [[0,'desc']],
lengthMenu: [10,25,50],
pageLength: 10,
dom: buttons.length ? 'Bfrtip' : 'frtip',
buttons: buttons
});
}
$(function(){
initTable('builder');
$('#btn-builder').on('click', () => initTable('builder'));
$('#btn-raw').on('click', () => initTable('raw'));
// Delegated delete action handler
$('#list').on('click', '[data-action="ajax-delete"]', function(e){
e.preventDefault();
const $el = $(this);
const id = $el.attr('data-id');
const name = $el.attr('data-name') || '';
if (!id) { alert('Missing id'); return; }
if (!confirm(`Delete "${name}" (id: ${id})?`)) return;
const payload = {};
payload[csrfName] = csrfHash;
payload.id = id;
$.post(urlDelete, payload, function(resp){
if (resp && resp.success) {
$('#list').DataTable().ajax.reload(null, false);
alert(resp.message || 'Deleted');
} else {
alert(resp.message || 'Delete failed');
}
}, 'json').fail(function(xhr){
alert('Request failed: ' + (xhr.responseJSON?.message ?? xhr.statusText));
});
});
});
})();
</script>
<?= $this->endSection() ?>
<?php
$routes->group('report', [
'namespace' => 'App\Controllers\Report',
'filter' => ['authGuard', 'RoleFilter:admin,user']
], static function ($routes) {
$routes->get('customer', 'CustomerController::index');
$routes->post('customer/ajaxBuilder', 'CustomerController::ajaxBuilder');
$routes->post('customer/ajaxRaw', 'CustomerController::ajaxRaw');
$routes->post('customer/delete', 'CustomerController::delete');
});
<?php
namespace App\Libraries;
class RowFormatter
{
protected array $columns = [];
protected array $rules = [];
public function __construct(array $columnsConfig = [], array $rules = [])
{
$this->columns = $columnsConfig;
$this->rules = $rules;
}
/**
* Main handler called for each row.
*/
public function handle(array $outRow, array $rawRow): array
{
// Per-column formatting
foreach ($this->columns as $colDef) {
$key = $colDef['data'];
$type = $colDef['type'] ?? 'text';
$val = $outRow[$key] ?? null;
if (isset($this->rules[$key])) {
$rule = $this->rules[$key];
if (is_callable($rule)) {
$outRow[$key] = $rule($val, $rawRow);
continue;
}
if (is_string($rule)) {
$outRow[$key] = $this->applyShorthand($rule, $val);
continue;
}
}
$outRow[$key] = $this->formatByType($type, $val);
}
// Actions handling: only 'group' wrapper supported
if (isset($this->rules['actions'])) {
$actionsRule = $this->rules['actions'];
// actions_wrapper is allowed but only 'group' will be used
$wrapper = $this->rules['actions_wrapper'] ?? 'group';
if (is_callable($actionsRule)) {
$html = $actionsRule($outRow, $rawRow);
} elseif (is_string($actionsRule) && $actionsRule === 'default') {
$defs = $this->rules['actions_default'] ?? [];
$html = $this->buildActionsGroup($defs, $outRow, $rawRow);
} elseif (is_array($actionsRule)) {
$html = $this->buildActionsGroup($actionsRule, $outRow, $rawRow);
} else {
$html = '';
}
$outRow['actions'] = $html;
} else {
// ensure actions key exists if defined in columns
foreach ($this->columns as $c) {
if ($c['data'] === 'actions') {
$outRow['actions'] = $outRow['actions'] ?? '';
break;
}
}
}
return $outRow;
}
/**
* Apply shorthand rule strings like date:..., phone_mask, float:N, uppercase.
*/
protected function applyShorthand(string $rule, $value)
{
if (str_starts_with($rule, 'date:')) {
$fmt = substr($rule, 5) ?: 'Y-m-d';
return !empty($value) ? date($fmt, strtotime($value)) : null;
}
if ($rule === 'phone_mask') {
return $this->maskPhone((string)$value);
}
if (str_starts_with($rule, 'float:')) {
$dec = (int) substr($rule, 6);
return is_numeric($value) ? number_format((float)$value, $dec, '.', '') : $value;
}
if ($rule === 'uppercase') {
return is_string($value) ? mb_strtoupper($value) : $value;
}
return $value;
}
/**
* Basic formatting by declared type.
*/
protected function formatByType(string $type, $value)
{
switch ($type) {
case 'text':
return is_null($value) ? null : esc($value);
case 'date':
return !empty($value) ? date('Y-m-d', strtotime($value)) : null;
case 'datetime':
return !empty($value) ? date('Y-m-d H:i:s', strtotime($value)) : null;
case 'number':
return is_numeric($value) ? (int)$value : $value;
case 'float':
return is_numeric($value) ? number_format((float)$value, 2, '.', '') : $value;
case 'currency':
return is_numeric($value) ? 'RM ' . number_format((float)$value, 2, '.', '') : $value;
case 'raw':
return $value;
default:
return $value;
}
}
/**
* Mask phone number by keeping last 4 digits.
*/
protected function maskPhone(string $phone): string
{
$digits = preg_replace('/\D+/', '', $phone);
$len = strlen($digits);
if ($len <= 4) {
return $phone;
}
return str_repeat('*', max(0, $len - 4)) . substr($digits, -4);
}
/**
* Build actions rendered as a Bootstrap button group.
*
* Each action def:
* - label (string)
* - url (string, may include {placeholders})
* - class (string) optional
* - attrs (array) optional
* - icon (string) optional
* - escape_label (bool) optional
*/
protected function buildActionsGroup(array $defs, array $outRow, array $rawRow): string
{
$items = [];
foreach ($defs as $def) {
$label = $def['label'] ?? '';
$url = $def['url'] ?? '#';
$cls = $def['class'] ?? 'btn btn-sm btn-secondary';
$attrs = $def['attrs'] ?? [];
$icon = $def['icon'] ?? '';
$escapeLabel = $def['escape_label'] ?? true;
// Replace placeholders before escaping
$url = $this->replacePlaceholders((string)$url, $outRow, $rawRow);
foreach ($attrs as $k => $v) {
$attrs[$k] = $this->replacePlaceholders((string)$v, $outRow, $rawRow);
}
// Escape URL and attributes
$urlEsc = esc($url);
$attrStr = '';
foreach ($attrs as $k => $v) {
$attrStr .= ' ' . $k . '="' . esc($v) . '"';
}
$labelHtml = $escapeLabel ? esc($label) : $label;
// Build single button HTML
$btnHtml = '<a href="' . $urlEsc . '" class="' . esc($cls) . '"' . $attrStr . '>' . $icon . $labelHtml . '</a>';
$items[] = $btnHtml;
}
// Wrap in btn-group
return '<div class="btn-group" role="group">' . implode(' ', $items) . '</div>';
}
/**
* Replace placeholders like {id} or {user.name} using rawRow first then outRow.
*/
protected function replacePlaceholders(string $template, array $outRow, array $rawRow): string
{
// Decode URL-encoded braces if present
$decodedTemplate = $template;
if (strpos($template, '%7B') !== false || strpos($template, '%7b') !== false) {
$decodedTemplate = rawurldecode($template);
}
$replaced = preg_replace_callback('/\{([a-zA-Z0-9_\.]+)\}/', function($m) use ($outRow, $rawRow) {
$path = $m[1];
$val = $this->fetchValueByPath($rawRow, $path);
if ($val === null) $val = $this->fetchValueByPath($outRow, $path);
return is_scalar($val) ? (string)$val : '';
}, $decodedTemplate);
return $replaced ?? $template;
}
/**
* Fetch value by dot-path from array.
*/
protected function fetchValueByPath($arr, string $path)
{
if (!is_array($arr)) return null;
if ($path === '') return null;
if (strpos($path, '.') === false) {
if (array_key_exists($path, $arr)) return $arr[$path];
$alt = str_replace('.', '_', $path);
return $arr[$alt] ?? null;
}
$parts = explode('.', $path);
$cur = $arr;
foreach ($parts as $p) {
if (!is_array($cur) || !array_key_exists($p, $cur)) return null;
$cur = $cur[$p];
}
return $cur;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment