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.
- 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])());
}
}
}
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'],
]);
}
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>
$fields_data and $row, NOT $data. The $fields_data variable
contains already formatted values, while $row is the database row object.
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(),
],
]);
}
- DO NOT pass
$form_idas second parameter tocreate() - The second parameter is
$page, use$this->page - Set the form ID with
->setId($form_id) - Set the current action with
->currentAction('home')
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();
}
$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>
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'],
];
}
$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
- Fetch-Based Modules - Base pattern for AJAX modules
- ChartBuilder Documentation - Complete ChartBuilder reference
- TableBuilder Documentation - Complete TableBuilder reference
- FormBuilder Documentation - Complete FormBuilder reference