Creating a Fetch-Based Table with Offcanvas
This guide demonstrates how to create tables that load and update via AJAX within an offcanvas panel. This pattern is ideal for selection dialogs, lookup tables, or any scenario where you need to display a searchable table in a modal context.
- Display Action: Shows the offcanvas with the initial table
- Update Action: Handles AJAX table updates (search, pagination, sorting)
The Two-Action Pattern
When a table uses AJAX updates (fetch mode), it needs two distinct request actions:
| Action Type | Purpose | Response Type | Called When |
|---|---|---|---|
| Display Action | Shows the offcanvas panel with initial table | JSON with offcanvas_end structure |
User clicks link to open offcanvas |
| Update Action | Updates the table content via AJAX | JSON with html and table_id |
User searches, sorts, or paginates |
Required TableBuilder Methods
1. activeFetch()
Enables AJAX mode for the table. When enabled, all table interactions (search, pagination, sorting) will use AJAX instead of full page reloads.
TableBuilder::create($model, 'myTableId')
->activeFetch() // Enable AJAX mode
->render();
2. setRequestAction()
Specifies which action should handle the AJAX table updates. This must match the name of your update action method.
TableBuilder::create($model, 'myTableId')
->activeFetch()
->setRequestAction('my-table-update') // Points to update action
->render();
?page=mypage&action=my-table-update
Complete Implementation Example
This example shows a product selection table in an offcanvas. Users can search products by name and select one.
Step 1: Create the Module
Create milkadmin/Modules/ProductSelectorModule.php:
<?php
namespace Modules;
use App\Abstracts\{AbstractModule, AbstractModel};
use App\Attributes\RequestAction;
use App\Response;
use Builders\{TableBuilder, SearchBuilder};
class ProductSelectorModule extends AbstractModule {
protected function configure($rule): void
{
$rule->page('productSelector')
->title('Product Selector')
->menu('Product Selector')
->access('public');
}
/**
* Display Action - Shows the offcanvas with the table
* Called when user clicks to open the product selector
*/
#[RequestAction('select-product')]
public function selectProduct() {
// Create search builder
$search = SearchBuilder::create('idTableProducts')
->search('search')
->placeholder('Search products...')
->layout('full-width');
// Create table builder
$table = $this->createProductTable();
// Return offcanvas response
$response = [
'title' => 'Select a Product',
'offcanvas_end' => [
'action' => 'show',
'title' => 'Select a Product',
'body' => $search->render() . '<br>' . $table->render()
]
];
Response::json($response);
}
/**
* Update Action - Handles AJAX table updates
* Called when user searches, sorts, or paginates
*/
#[RequestAction('select-product-update-table')]
public function selectProductUpdateTable() {
$table = $this->createProductTable();
Response::json($table->getResponse());
}
/**
* Helper method to create the table configuration
* Shared between display and update actions
*/
private function createProductTable() {
return TableBuilder::create($this->model, 'idTableProducts')
->activeFetch() // Enable AJAX mode
->setRequestAction('select-product-update-table') // Point to update action
->resetFields()
->field('name')->label('Product Name')
->field('price')->label('Price')
->fn(function($row) {
return '$' . number_format($row->price, 2);
})
->field('category')->label('Category')
->addAction('select', [
'label' => 'Select',
'link' => '?page=productSelector&action=product-selected&id=%id%',
'class' => 'btn-primary btn-sm',
'icon' => 'bi-check-circle',
'fetch' => 'get'
]);
}
/**
* Action called when user selects a product
*/
#[RequestAction('product-selected')]
public function productSelected() {
$product_id = _absint($_REQUEST['id'] ?? 0);
$product = $this->model->find($product_id);
$response = [
'alert' => [
'type' => 'success',
'message' => 'Product selected: ' . $product->name
],
'offcanvas_end' => [
'action' => 'hide' // Close the offcanvas
]
];
Response::json($response);
}
}
/**
* Simple Product Model for demonstration
*/
class ProductSelectorModel extends AbstractModel {
protected function configure($rule): void
{
$rule
->table('products')
->id()
->string('name', 100)->label('Product Name')
->decimal('price', 10, 2)->label('Price')
->string('category', 50)->label('Category');
}
}
Step 2: Add a Link to Open the Offcanvas
From any other page, add a link that triggers the display action:
<a href="?page=productSelector&action=select-product"
class="btn btn-primary"
data-fetch="get">
<i class="bi bi-search"></i> Select Product
</a>
Workflow Diagram
┌─────────────────────────────────────────────────────────┐
│ 1. User clicks "Select Product" link │
│ data-fetch="get" │
└────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 2. Display Action: select-product │
│ - Creates SearchBuilder │
│ - Creates TableBuilder with activeFetch() │
│ - Returns offcanvas_end response │
└────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 3. Offcanvas opens with table │
│ - Table is in AJAX mode │
│ - setRequestAction points to update action │
└────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 4. User searches/sorts/paginates │
│ - Table sends AJAX request │
└────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 5. Update Action: select-product-update-table │
│ - Creates same TableBuilder │
│ - Returns getResponse() with updated HTML │
└────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 6. Table content updates via AJAX │
│ - Offcanvas stays open │
│ - Only table HTML is replaced │
└─────────────────────────────────────────────────────────┘
Understanding getResponse()
The getResponse() method returns a JSON array for AJAX updates:
$table->getResponse();
// Returns:
[
'html' => '<table>...</table>', // Updated table HTML
'table_id' => 'idTableProducts' // Table identifier
]
getResponse(), NOT render(). The render() method returns HTML string, while getResponse() returns the JSON structure needed for AJAX updates.
Integrating SearchBuilder
SearchBuilder automatically links to tables by sharing the same table ID:
// Both use the same ID: 'idTableProducts'
$search = SearchBuilder::create('idTableProducts')
->search('search')
->placeholder('Type to search...');
$table = TableBuilder::create($model, 'idTableProducts')
->activeFetch()
->setRequestAction('update-action');
SearchBuilder Methods Used
| Method | Description |
|---|---|
create($table_id) |
Static factory method. Creates a SearchBuilder linked to a specific table ID. |
search($filter_type) |
Creates a search input field. The filter_type parameter must match a filter defined in TableBuilder. |
select($filter_type) |
Creates a select dropdown filter. |
placeholder($text) |
Sets placeholder text for input fields. |
layout($type) |
Controls field layout. Options: 'inline', 'full-width', 'stacked'. |
render() |
Outputs the search form HTML. |
Advanced Example: Multiple Filters
#[RequestAction('advanced-select')]
public function advancedSelect() {
$search = SearchBuilder::create('idTableAdvanced')
->setWrapperClass('d-flex align-items-center gap-2')
// Text search
->search('search')
->label('Search')
->placeholder('Product name...')
->layout('inline')
// Category filter
->select('category_filter')
->label('Category')
->options([
'' => 'All Categories',
'electronics' => 'Electronics',
'clothing' => 'Clothing',
'food' => 'Food'
])
->selected($_REQUEST['category'] ?? '')
->layout('inline')
$table = TableBuilder::create($this->model, 'idTableAdvanced')
->activeFetch()
->setRequestAction('advanced-select-update')
// Define filters matching SearchBuilder
->filter('category_filter', function($query, $value) {
if (!empty($value)) {
$query->where('category = ?', [$value]);
}
}, $_REQUEST['category'] ?? '')
->resetFields()
->field('name')->label('Product')
->field('category')->label('Category')
->field('price')->label('Price');
$response = [
'offcanvas_end' => [
'action' => 'show',
'title' => 'Advanced Product Selection',
'body' => $search->render() . '<br>' . $table->render()
]
];
Response::json($response);
}
#[RequestAction('advanced-select-update')]
public function advancedSelectUpdate() {
$table = TableBuilder::create($this->model, 'idTableAdvanced')
->activeFetch()
->setRequestAction('advanced-select-update')
->filter('category_filter', function($query, $value) {
if (!empty($value)) {
$query->where('category = ?', [$value]);
}
}, $_REQUEST['category'] ?? '')
->resetFields()
->field('name')->label('Product')
->field('category')->label('Category')
->field('price')->label('Price');
Response::json($table->getResponse());
}
offcanvas_end Response Structure
The display action returns a JSON response with the offcanvas_end key:
$response = [
'offcanvas_end' => [
'action' => 'show', // 'show' to open, 'hide' to close
'title' => 'Offcanvas Title', // Title displayed in header
'body' => '<html>...</html>', // Content (search + table)
'size' => 'lg' // Optional: 'sm', 'lg', 'xl' (default: md)
]
];
Response::json($response);
| Property | Values | Description |
|---|---|---|
action |
'show', 'hide' | Controls offcanvas visibility |
title |
string | Text displayed in offcanvas header |
body |
HTML string | Content to display in offcanvas body |
size |
'sm', 'lg', 'xl' | Optional width size (default: medium) |
Common Mistakes and Solutions
| Problem | Cause | Solution |
|---|---|---|
| Table doesn't update when searching | Missing activeFetch() |
Add ->activeFetch() to TableBuilder |
| AJAX request goes to wrong action | Missing or wrong setRequestAction() |
Ensure setRequestAction('action-name') matches your update action |
| Update action returns blank table | Using render() instead of getResponse() |
Change to Response::json($table->getResponse()) |
| Filters not working | SearchBuilder filter_type doesn't match TableBuilder filter | Ensure filter names match in both builders |
| Table configuration differs between actions | Duplicate code in both actions | Extract table creation to a helper method |
| SearchBuilder doesn't trigger table updates | Different table IDs | Use the same table ID for both SearchBuilder and TableBuilder |
Best Practices
- Share Configuration: Create a helper method for table configuration to ensure display and update actions use identical settings
- Match Table IDs: SearchBuilder and TableBuilder must use the same table ID
- Match Filter Names: Filter names in SearchBuilder must exactly match those in TableBuilder
- Always Use getResponse(): Update actions must return
getResponse(), notrender() - Keep Actions Simple: Display action sets up the offcanvas, update action only handles table updates
Related Documentation
- Form Containers: Organizing form fields with Bootstrap grid layouts
- TableBuilder Actions: Adding custom actions and buttons to tables
- Response System: Understanding JSON responses and fetch behavior