Milk Admin

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.

Key Concept: Fetch-based tables require two separate actions:
  1. Display Action: Shows the offcanvas with the initial table
  2. 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();
✓ Effect: The table will submit filter changes, pagination, and sorting via AJAX requests instead of page reloads.

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();
✓ Effect: When the table needs to update, it will send an AJAX request to ?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>
✓ Result: When clicked, opens an offcanvas with a searchable product table. All table interactions happen via AJAX without closing the offcanvas.

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
]
Important: The update action must return 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');
✓ Effect: When user types in the search box, the table automatically updates via the specified request 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(), not render()
  • 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
Loading...