Milk Admin

Multi-Builder Page with Dynamic Updates

Revision: 2026/01/14

Learn how to create a complex page with multiple builders (Chart, Table, List, Form) that update dynamically via fetch without reloading the page.

What you'll learn:
  • How to configure multiple builders on the same page
  • How to implement dynamic update patterns (reload)
  • How to use SearchBuilder to filter multiple elements
  • Common mistakes to avoid and best practices
  • How to create custom templates for ListBuilder

Structure

milkadmin_local/Modules/UpdatePatterns/
├── UpdatePatternsModel.php       # Database model
├── UpdatePatternsModule.php      # Controller with actions
└── Views/
    ├── UpdatePatternsView.php    # Main view
    ├── custom_content.php        # Custom HTML content
    └── list-item-template.php    # Custom list template

Step 1: Configure the Module

Let's start by defining constants for the various builder IDs. It's important to use constants to avoid typos. Each element will have its own unique ID that will allow us to identify and update it independently from the others.

<?php
namespace Local\Modules\UpdatePatterns;

use App\Abstracts\AbstractModule;
use App\Attributes\RequestAction;
use App\Response;
use Builders\{ChartBuilder, TableBuilder, ListBuilder, FormBuilder, SearchBuilder};

class UpdatePatternsModule extends AbstractModule
{
    // Definisci ID come costanti per evitare errori
    private const CHART_ID = 'idUpdatePatternsChart';
    private const TABLE_ID = 'idUpdatePatternsTable';
    private const LIST_ID = 'idUpdatePatternsList';
    private const FORM_ID = 'formUpdatePatterns';

    protected function configure($rule): void
    {
        $rule->page('updatepatterns')
            ->title('Update Patterns Test')
            ->menu('Update Patterns', '', 'bi bi-arrow-clockwise', 80)
            ->access('public')
            ->version(260114);
    }
}

Step 2: Main Action (home)

The main action creates all the builders, handles partial responses for AJAX, and renders the view.

<?php
#[RequestAction('home')]
public function home(): void
{
    // Create all builders
    $chartBuilder = $this->buildChart(self::CHART_ID);
    $tableBuilder = $this->buildTable(self::TABLE_ID);
    $listBuilder = $this->buildList(self::LIST_ID);
    $formBuilder = $this->buildForm(self::FORM_ID);

    // Handle partial requests for Chart, Table, List
    $this->respondPartial([
        'chart_id' => [self::CHART_ID => fn() => $chartBuilder->getResponse()],
        'table_id' => [self::TABLE_ID => fn() => $tableBuilder->getResponse()],
        'list_id' => [self::LIST_ID => fn() => $listBuilder->getResponse()],
    ]);

    // SearchBuilder shared between chart, table and list
    $search = SearchBuilder::create([self::CHART_ID, self::TABLE_ID, self::LIST_ID])
        ->setWrapperClass('d-flex align-items-center gap-2')
        ->select('category_filter')
            ->label('Category')
            ->options(['' => 'All', 'A' => 'Category A', 'B' => 'Category B'])
            ->layout('inline');

    // Prepare data for the view
    $response = $this->getCommonData();
    $response['chart_html'] = $chartBuilder->render();
    $response['table_html'] = $tableBuilder->render();
    $response['list_html'] = $listBuilder->render();
    $response['form_html'] = $formBuilder->render();
    $response['search_html'] = $search->render();

    Response::render(__DIR__ . '/Views/UpdatePatternsView.php', $response);
}

// Helper to handle partial AJAX responses
private function respondPartial(array $routes): void
{
    foreach (['chart_id', 'table_id', 'list_id'] as $request_key) {
        $requested_id = $_REQUEST[$request_key] ?? '';
        if ($requested_id && isset($routes[$request_key][$requested_id])) {
            Response::htmlJson(($routes[$request_key][$requested_id])());
        }
    }
}
Best Practice: Use respondPartial() to handle AJAX requests from builders. This pattern allows Chart, Table, and List to reload independently when filters change.

Step 3: Configure ChartBuilder

ChartBuilder requires data aggregation to generate the chart.

<?php
private function buildChart(string $chart_id): ChartBuilder
{
    return ChartBuilder::create($this->model, $chart_id)
        ->setRequestAction('home')  // Action for AJAX reload
        ->select([
            'category AS label',
            'SUM(value) AS value',
            'MIN(id) AS sort_id'
        ])
        ->filter('category_filter', function($query, $value) {
            if ($value !== '') $query->where('category = ?', [$value]);
        })
        ->groupBy('category')
        ->orderBy('sort_id', 'ASC')
        ->structure([
            'label' => ['label' => 'Category', 'axis' => 'x'],
            'value' => ['label' => 'Total', 'type' => 'bar', 'color' => '#0d6efd'],
        ]);
}
Common Error #2 - Canvas Double Initialization: If you use ChartBuilder, DO NOT add inline scripts to initialize the chart. The framework has an automatic system (ChartComponent.js) that handles initialization via .js-chart-container. Adding duplicate scripts causes the error "Canvas is already in use".

Step 4: Configure TableBuilder

TableBuilder is the simplest, it displays data in tabular format.

<?php
private function buildTable(string $table_id): TableBuilder
{
    return TableBuilder::create($this->model, $table_id)
        ->setRequestAction('home')
        ->filter('category_filter', function($query, $value) {
            if ($value !== '') $query->where('category = ?', [$value]);
        })
        ->orderBy('id', 'DESC')
        ->limit(5)
        ->resetFields()
        ->field('id')->label('#')
        ->field('label')->label('Label')
        ->field('category')->label('Category')
        ->field('value')->label('Value')
        ->field('data')->label('Date');
}

Step 5: Configure ListBuilder

ListBuilder displays data as a Bootstrap list. It can use custom templates.

<?php
private function buildList(string $list_id): ListBuilder
{
    return ListBuilder::create($this->model, $list_id)
        ->setRequestAction('home')
        ->filter('category_filter', function($query, $value) {
            if ($value !== '') $query->where('category = ?', [$value]);
        })
        ->orderBy('id', 'DESC')
        ->limit(3)
        ->resetFields()
        ->field('label')->label('')
        ->field('category')->label('')
        ->setBoxTemplate(__DIR__ . '/Views/list-item-template.php');
}

Custom Template for ListBuilder

Create a file Views/list-item-template.php to customize the layout of each item:

<?php
/**
 * Available variables:
 * @var object $row - The current row from the database
 * @var array $fields_data - Formatted data [col_name => (object) ['label', 'value', 'type']]
 * @var string $primary - Name of the primary key field
 */

$label = $fields_data['label']->value ?? '';
$category = $fields_data['category']->value ?? '';
$id_value = isset($row->$primary) ? $row->$primary : '';
?>

<div class="list-group-item d-flex justify-content-between align-items-center">
    <div>
        <strong><?php _p($label); ?></strong>
        <?php if ($category): ?>
            <small class="text-muted ms-2"><?php _p($category); ?></small>
        <?php endif; ?>
    </div>
    <span class="badge bg-primary rounded-pill">#<?php _p($id_value); ?></span>
</div>
Common Error #3 - Wrong Template Variables: In ListBuilder template use $fields_data and $row, NOT $data. The $fields_data variable contains already formatted values, while $row is the database row object.
Note on Default Classes: As of MilkAdmin 260114, the default classes for ListBuilder are list-group for the container and col-12 for columns, appropriate for vertical lists. For grid layouts, use gridColumns().

Step 6: Configure FormBuilder

FormBuilder handles data creation and editing. It requires special configuration for the action.

<?php
private function buildForm(string $form_id): FormBuilder
{
    return FormBuilder::create($this->model, $this->page)  // IMPORTANT: use $this->page
        ->setId($form_id)                                  // Set ID separately
        ->currentAction('home')                            // Set current action
        ->resetFields()
        ->field('label')
            ->label('Label')
            ->required()
        ->field('category')
            ->label('Category')
            ->formType('select')
            ->options(['A' => 'Category A', 'B' => 'Category B'])
            ->required()
        ->field('value')
            ->label('Value')
            ->formType('number')
            ->required()
        ->addActions([
            'save' => [
                'label' => 'Save',
                'class' => 'btn btn-primary',
                'action' => [$this, 'saveFormAction'],
            ],
            'reload' => [
                'label' => 'Reload',
                'type' => 'button',
                'class' => 'btn btn-secondary',
                'validate' => false,
                'onclick' => 'milkFormReload(this); return false;',
                'action' => FormBuilder::reloadAction(),
            ],
        ]);
}
Common Error #4 - FormBuilder Configuration:
  • DO NOT pass $form_id as second parameter to create()
  • The second parameter is $page, use $this->page
  • Set the form ID with ->setId($form_id)
  • Set the current action with ->currentAction('home')
Best Practice: The "Reload" button uses FormBuilder::reloadAction() to restore database values without validation. Useful for canceling changes or updating calculated fields.

Step 7: Actions for Dynamic Reload

Each builder has a dedicated action that adds data and returns a reload command.

<?php
#[RequestAction('reload-chart')]
public function reloadChart(): void
{
    $this->addRandomData();  // Add new data
    Response::json([
        'success' => true,
        'list' => ['id' => self::CHART_ID, 'action' => 'reload'],
        'toast' => ['message' => 'Chart reloaded!', 'type' => 'success']
    ]);
}

#[RequestAction('reload-table')]
public function reloadTable(): void
{
    $this->addRandomData();
    Response::json([
        'success' => true,
        'list' => ['id' => self::TABLE_ID, 'action' => 'reload'],
        'toast' => ['message' => 'Table reloaded!', 'type' => 'info']
    ]);
}

#[RequestAction('reload-list')]
public function reloadList(): void
{
    $this->addRandomData();
    Response::json([
        'success' => true,
        'list' => ['id' => self::LIST_ID, 'action' => 'reload'],
        'toast' => ['message' => 'List reloaded!', 'type' => 'warning']
    ]);
}

private function addRandomData(): void
{
    $item = new UpdatePatternsModel();
    $item->label = 'Test ' . rand(1, 100);
    $item->category = ['A', 'B', 'C'][array_rand(['A', 'B', 'C'])];
    $item->data = date('Y-m-d');
    $item->value = rand(10, 100);
    $item->save();
}
Reload Pattern: Use $response['list'] with action: 'reload' to reload Chart, Table or List. The automatic JavaScript (list.reload()) handles the update without reloading the page.

Step 8: View Template

The view organizes the builders in a Bootstrap layout. The buttons use data-fetch="post" for AJAX calls.

<?php
!defined('MILK_DIR') && die();
?>
<div class="bg-white p-4">
    <h1>Update Patterns - Test Builders</h1>

    <!-- Shared SearchBuilder -->
    <div class="card mb-4">
        <div class="card-body">
            <?php echo $search_html; ?>
        </div>
    </div>

    <div class="row">
        <!-- ChartBuilder -->
        <div class="col-lg-6 mb-4">
            <div class="card">
                <div class="card-header d-flex justify-content-between">
                    <h5>Chart</h5>
                    <a href="?page=updatepatterns&action=reload-chart"
                       data-fetch="post"
                       class="btn btn-sm btn-outline-primary">
                        <i class="bi bi-arrow-clockwise"></i> Reload
                    </a>
                </div>
                <div class="card-body">
                    <?php echo $chart_html; ?>
                </div>
            </div>
        </div>

        <!-- TableBuilder -->
        <div class="col-lg-6 mb-4">
            <div class="card">
                <div class="card-header d-flex justify-content-between">
                    <h5>Table</h5>
                    <a href="?page=updatepatterns&action=reload-table"
                       data-fetch="post"
                       class="btn btn-sm btn-outline-info">
                        <i class="bi bi-arrow-clockwise"></i> Reload
                    </a>
                </div>
                <div class="card-body">
                    <?php echo $table_html; ?>
                </div>
            </div>
        </div>

        <!-- ListBuilder -->
        <div class="col-lg-6 mb-4">
            <div class="card">
                <div class="card-header d-flex justify-content-between">
                    <h5>List</h5>
                    <a href="?page=updatepatterns&action=reload-list"
                       data-fetch="post"
                       class="btn btn-sm btn-outline-warning">
                        <i class="bi bi-arrow-clockwise"></i> Reload
                    </a>
                </div>
                <div class="card-body">
                    <?php echo $list_html; ?>
                </div>
            </div>
        </div>

        <!-- FormBuilder -->
        <div class="col-lg-6 mb-4">
            <div class="card">
                <div class="card-header">
                    <h5>Data Entry Form</h5>
                </div>
                <div class="card-body">
                    <?php echo $form_html; ?>
                </div>
            </div>
        </div>
    </div>
</div>
Important: data-fetch works ONLY on <a> tags, NOT on <button>. Parameters go in the href attribute and are automatically converted to POST by the framework.

Step 9: Form Save Action

The form save action can trigger the reload of other builders.

<?php
public function saveFormAction(FormBuilder $formBuilder, array $request): array
{
    $data = $request['data'] ?? [];
    $item_id = _absint($data['id'] ?? 0);

    if ($item_id > 0) {
        $item = $this->model->getById($item_id);
        if (!$item) {
            return ['success' => false, 'message' => 'Record not found'];
        }
    } else {
        $item = new UpdatePatternsModel();
    }

    $item->label = $data['label'] ?? '';
    $item->category = $data['category'] ?? '';
    $item->value = _absint($data['value'] ?? 0);
    $item->data = $data['data'] ?? date('Y-m-d');

    if (!$item->save()) {
        return ['success' => false, 'message' => 'Error during save'];
    }

    // After saving, reload the table
    return [
        'success' => true,
        'message' => 'Saved successfully!',
        'list' => ['id' => self::TABLE_ID, 'action' => 'reload'],
    ];
}
Coordinated Pattern: When you save in the form, you can trigger the table (or chart/list) reload to immediately show the new record. Use $response['list'] in the action response.

Recap: Common Errors to Avoid

Error Problem Solution
#1 Duplicate IDs Hardcoded strings cause mismatch Use private const for all IDs
#2 Canvas double init Inline script + ChartComponent Remove inline script, use only automatic ChartComponent
#3 Template variables Using $data instead of $fields_data In ListBuilder template: $fields_data and $row
#4 FormBuilder config Passing form_id as $page parameter Use ->setId() and ->currentAction()
#5 data-fetch on button data-fetch doesn't work on <button> Always use <a> tag with data-fetch

JSON Response Pattern

All builders respond with JSON containing specific keys to control the UI:

Key Description Example
list Reload Chart, Table or List ['id' => 'tableId', 'action' => 'reload']
toast Show notification ['message' => 'Saved!', 'type' => 'success']
element Update custom HTML ['selector' => '#div', 'innerHTML' => $html]
redirect Navigate to URL '?page=tasks'
window_reload Reload full page true

When to Use This Pattern

  • Complex dashboards: Show the same data in different formats
  • Data exploration: Filter and visualize data from multiple perspectives
  • CRUD with preview: Data entry form + results table/list
  • Dynamic reporting: Chart + detail table + filter form
  • Resource management: Card list + table + edit form

Reference Module

The complete UpdatePatterns module is available at milkadmin_local/Modules/UpdatePatterns/ and demonstrates all the patterns discussed in this article.

You can test it by accessing: ?page=updatepatterns


See Also

Loading...