Managing Related Content with Fetch-Based Interface
Revision: 2025/12/16
This guide demonstrates how to manage related content (Recipe → Comments) using fetch/AJAX instead of page reload. The comments list opens in an offcanvas, the edit form in a modal, and all operations (sorting, searching, pagination, save, delete) happen without reloading the main page.
1. Overview
In a fetch-based approach, clicking on "Comments" opens an offcanvas sidebar with the comments list. Clicking "Edit" opens a modal with the form. All operations use AJAX requests, and the server responds with JSON instructions to control the UI elements.
The key mechanism is the activeFetch() method available on TableBuilder and FormBuilder, combined with data-fetch attributes on links and Response::json() for server responses.
2. Page Reload vs Fetch: Architecture Comparison
Page Reload Approach (Posts Module)
User Flow:
- Click "Comments" → loads new page with comments list
- Click "Edit Comment" → loads new page with form
- Save form → redirects to comments list page
- Every action requires a full page reload
Fetch Approach (Recipe Module)
User Flow:
- Click "Comments" → fetch request → opens offcanvas with comments list (no page reload)
- Sort/search/pagination → fetch request → updates only table HTML in offcanvas
- Click "Edit Comment" → fetch request → opens modal with form
- Save form → fetch request → closes modal, reloads comments table and updates counter in main list
Comparison Table
| Aspect | Page Reload | Fetch |
|---|---|---|
| Server Response | Complete HTML page | JSON with UI instructions |
| FormBuilder | Third parameter = redirect URL->getForm() returns HTML |
->activeFetch()->getResponse() returns array |
| TableBuilder | Normal links | ->activeFetch()->setRequestAction() |
| Links to Comments | <a href="..."> |
<a href="..." data-fetch="post"> |
| Response Method | Response::render() |
Response::json() |
| Architecture | Separate Controller | Module with methods + Service class |
| User Experience | Loads complete page | Opens offcanvas/modal, no reload |
3. Step 1: Create the Models
RecipeModel (Main Model)
Create milkadmin_local/Modules/Recipe/RecipeModel.php:
<?php
namespace Local\Modules\Recipe;
use App\Abstracts\AbstractModel;
class RecipeModel extends AbstractModel
{
protected function configure($rule): void
{
$rule->table('#__recipes')
->id()
->hasMany('comments', RecipeCommentsModel::class, 'recipe_id')
->title('name')->index()
->text('ingredients')->formType('textarea')
->select('difficulty', ['Easy', 'Medium', 'Hard']);
}
}
->hasMany('comments', RecipeCommentsModel::class, 'recipe_id')- Defines one-to-many relationship:'comments'- Alias for accessing related comments ($recipe->comments)RecipeCommentsModel::class- The related model'recipe_id'- Foreign key in the comments table
RecipeCommentsModel (Related Model)
Create milkadmin_local/Modules/Recipe/RecipeCommentsModel.php:
<?php
namespace Local\Modules\Recipe;
use App\Abstracts\AbstractModel;
class RecipeCommentsModel extends AbstractModel
{
protected function configure($rule): void
{
$rule->table('#__recipe_comments')
->id()
->int('recipe_id')->formType('hidden')
->text('comment');
}
}
->int('recipe_id')->formType('hidden')- Foreign key field hidden in forms- The
hiddenform type automatically hides it in the interface
4. Step 2: Register Models in Module
Create milkadmin_local/Modules/Recipe/RecipeModule.php:
<?php
namespace Local\Modules\Recipe;
use App\Abstracts\AbstractModule;
use App\Attributes\{RequestAction};
use Builders\{TableBuilder, FormBuilder};
use App\Response;
class RecipeModule extends AbstractModule
{
protected function configure($rule): void {
$rule->page('recipes')
->title('My Recipes')
->menu('Recipes', '', 'bi bi-book', 10)
->access('registered')
->addModels(['comment' => RecipeCommentsModel::class]);
}
// Controller methods will be added here
}
->addModels(['comment' => RecipeCommentsModel::class])- Registers CommentsModel as an additional model- In this module, the Module class contains both configuration and controller methods (unlike Posts where there's a separate PostsController)
5. Step 3: Main List with Fetch-Enabled Comments Link
Add this method in RecipeModule:
#[RequestAction('home')]
public function recipesList() {
$tableBuilder = TableBuilder::create($this->model, 'idTableRecipes')
->activeFetch()
->field('name')
->link('?page='.$this->page.'&action=edit&id=%id%')
->field('comments')
->label('Comments')
->fn(function ($row) {
$comments = count($row->comments);
return '<a href="?page='.$this->page.'&action=comments&recipe_id='.$row->id.'" data-fetch="post">'.$comments.' <i class="bi bi-chat-dots"></i></a>';
})
->setDefaultActions();
$response = array_merge($this->getCommonData(), $tableBuilder->getResponse());
Response::render(__DIR__ . '/Views/list_page.php', $response);
}
Key elements:
->activeFetch()- Enables fetch mode for the table. Action links (edit, delete) are handled via fetch instead of normal navigation.->field('comments')->fn(...)- Creates a custom column using the hasMany relationship:count($row->comments)- Counts comments using the relationship- Returns HTML with a clickable link
data-fetch="post"- Critical attribute that tells JavaScript to:- Intercept the click event
- Make a fetch POST request instead of navigating
- Without this attribute, the link would cause a normal page reload
Page Reload Approach
// No activeFetch
TableBuilder::create($model, 'idTablePosts')
->field('comments')->fn(function($row) {
return count($row->comments); // Just number
})
->addAction('comment', [
'label' => 'Comments',
'link' => '?page=posts&action=comments&post_id=%id%'
// Normal link, causes page reload
]);
Fetch Approach
// With activeFetch
TableBuilder::create($model, 'idTableRecipes')
->activeFetch() // Enable fetch mode
->field('comments')->fn(function ($row) {
// Returns HTML link with data-fetch
return '<a href="..." data-fetch="post">'
.$comments.'</a>';
});
6. Step 4: RecipeService Class - Separating Business Logic
Create milkadmin_local/Modules/Recipe/RecipeService.php:
This class doesn't exist in the Posts module. It serves to separate business logic from controller methods, making the code more organized and reusable.
<?php
namespace Local\Modules\Recipe;
use Builders\{TableBuilder, FormBuilder, TitleBuilder, SearchBuilder};
use App\Response;
class RecipeService
{
// Methods explained in following sections
}
6.1 getRecipeId() - Validation
public static function getRecipeId(): RecipeModel {
$model = new RecipeModel();
$recipe_id = $_POST['data']['recipe_id'] ?? $_REQUEST['recipe_id'] ?? 0;
if ($recipe_id == 0) {
Response::json(['success' => false, 'msg' => 'Recipe ID not provided']);
}
$recipe = $model->getById($recipe_id);
if ($recipe->isEmpty()) {
Response::json(['success' => false, 'msg' => 'Recipe not found']);
}
return $recipe;
}
- Retrieves
recipe_idfrom$_POST['data']['recipe_id'](from form submissions) or$_REQUEST['recipe_id'](from URL) - Validates that it exists and the recipe exists in the database
- Responds with JSON on error (not redirect)
- Returns the complete RecipeModel object, not just the ID
6.2 getCommentOffcanvasHtml() - First Load Complete HTML
public static function getCommentOffcanvasHtml(RecipeModel $recipe) {
$title = TitleBuilder::create($recipe->name)
->addButton('New Comment', '?page=recipe&action=comment-edit&recipe_id=' . $recipe->id , 'primary', '', 'post');
$search = SearchBuilder::create('idTableRecipeComments')
->search('search')
->layout('full-width')
->placeholder('Type to search...');
$table = self::getCommentTable($recipe);
return $title->render().'<br>'.$search->render().'<br>'.$table->render();
}
This method builds the complete HTML for the first offcanvas load:
- TitleBuilder - Creates the title with the recipe name:
->addButton(...)- "New Comment" button- Fifth parameter
'post'- Addsdata-fetch="post"attribute to the button
- SearchBuilder - Creates the search bar:
->search('search')- Search field name- Connected to
'idTableRecipeComments'- When searching, updates that table
- TableBuilder - Via
getCommentTable() - Returns concatenated HTML: title + search + table
Why needed: When you click "Comments", the offcanvas opens and must immediately show title, search, and table. Later, when sorting/searching/paginating, only the table is updated, not the entire HTML.
6.3 getCommentTable() - Table with Fetch Actions
public static function getCommentTable(RecipeModel $recipe): TableBuilder {
$commentsModel = new RecipeCommentsModel();
return TableBuilder::create($commentsModel, 'idTableRecipeComments')
->activeFetch()
->setRequestAction('update-comment-table')
->where('recipe_id = ?', [$recipe->id])
->field('comment')->truncate(100)
->addAction('edit', [
'label' => 'Edit',
'link' => '?page=recipe&action=comment-edit&recipe_id='.$recipe->id.'&id=%id%',
])
->addAction('delete', [
'label' => 'Delete',
'action' => [self::class, 'deleteComment'],
])
->customData('recipe_id', $recipe->id);
}
Method breakdown:
->activeFetch()- Enables fetch mode:- Actions (edit, delete) are handled via fetch
- Links don't cause page reload
->setRequestAction('update-comment-table')- Fundamental:- When sorting, searching, or paginating the table, calls the
update-comment-tableaction - Instead of the default behavior which would reload the page
- This action returns only the updated table HTML
- When sorting, searching, or paginating the table, calls the
->where('recipe_id = ?', [$recipe->id])- Filters comments:- Shows only comments for the current recipe
- This filter is maintained during sorting/searching/pagination
->customData('recipe_id', $recipe->id)- Passes extra data:- The
recipe_idis included in all table requests - Necessary for the delete action and update-comment-table action
- Without this, update-comment-table wouldn't know which recipe to filter
- The
->addAction('delete', ['action' => [self::class, 'deleteComment']])- Custom callback:- Instead of
'link', uses'action'with a callback - The
deleteCommentcallback is called directly
- Instead of
6.4 getCommentForm() - Form in Modal with Auto-Reload
public static function getCommentForm(RecipeModel $recipe) {
$commentsModel = new RecipeCommentsModel();
return FormBuilder::create($commentsModel, 'recipe')
->activeFetch()
->asModal()
->customData('recipe_id', $recipe->id)
->setTitle('New Comment', 'Edit Comment')
->dataListId('idTableRecipeComments')
->field('recipe_id')->value($recipe->id)->readonly()
->field('comment')->required()
->setActions([
'save' => [
'label' => 'Save',
'class' => 'btn btn-primary',
'action' => FormBuilder::saveAction()
]
]);
}
Method breakdown:
->activeFetch()- Enables fetch mode for the form:- Submit is handled via fetch instead of normal POST
- Response is JSON, not redirect
->asModal()- Opens the form in a modal:- Instead of a separate page
- The modal overlays the offcanvas
->customData('recipe_id', $recipe->id)- Passes hidden recipe_id:- Included in form data on submit
- Necessary to save the comment associated with the correct recipe
->setTitle('New Comment', 'Edit Comment')- Custom titles:- First parameter: title when creating new (no id present)
- Second parameter: title when editing (id present)
->dataListId('idTableRecipeComments')- FUNDAMENTAL:- After save, automatically reloads the table with id
idTableRecipeComments - You immediately see the added/modified comment without closing the offcanvas
- After save, automatically reloads the table with id
->field('recipe_id')->value($recipe->id)->readonly()- Readonly field:- Pre-fills the field with recipe_id
- Makes it readonly to prevent changes
->setActions([...])- Defines form buttons:FormBuilder::saveAction()- Standard save action- Could add other buttons (cancel, delete, etc.)
6.5 deleteComment() - Custom Delete Action
public static function deleteComment($record, $request) {
if($record->delete($record->id)) {
return ['success' => true, 'message' => 'Item deleted successfully'];
}
return ['success' => false, 'message' => 'Delete failed'];
}
- Callback used by the delete action in the table
- Receives
$record(RecipeCommentsModel object) and$request(request data) - Returns array with success and message (automatically handled by the system)
7. Step 5: Module Controller Actions
Add these methods in RecipeModule (after the configure method):
7.1 recipeEdit() - Edit Recipe in Offcanvas
#[RequestAction('edit')]
public function recipeEdit() {
$response = ['page' => $this->page, 'title' => $this->title];
$response = array_merge($response, FormBuilder::create($this->model, $this->page)
->asOffcanvas()
->activeFetch()
->setTitle('New Recipe', 'Edit Recipe')
->dataListId('idTableRecipes')
->getResponse());
Response::json($response);
}
Form for editing the main recipe (not comments):
->asOffcanvas()- Opens in offcanvas instead of page->activeFetch()- Submit via fetch->dataListId('idTableRecipes')- Reloads main list after save->getResponse()- Returns array for Response::json(), NOT HTML
You can directly control where forms open using FormBuilder methods:
->asOffcanvas()- Opens form in an offcanvas sidebar->asModal()- Opens form in a centered modal- Without these methods, the form renders in a full page (page reload approach)
Page Reload Approach
public function postEdit() {
$response = $this->getCommonData();
$response['form'] = FormBuilder::create(
$this->model,
$this->page
)->getForm(); // Returns HTML
Response::render(
MILK_DIR . '/Theme/SharedViews/edit_page.php',
$response
);
}
Fetch Approach
public function recipeEdit() {
$response = ['page' => $this->page, 'title' => $this->title];
$response = array_merge($response,
FormBuilder::create($this->model, $this->page)
->asOffcanvas() // Opens in offcanvas
->activeFetch() // Fetch mode
->dataListId('idTableRecipes')
->getResponse() // Returns array
);
Response::json($response);
}
7.2 recipeComments() - Open Offcanvas with Comments List
#[RequestAction('comments')]
public function recipeComments() {
$recipe = RecipeService::getRecipeId();
Response::json([
'offcanvas_end' => [
'title' => 'Comments',
'body' => RecipeService::getCommentOffcanvasHtml($recipe),
'size' => 'lg'
]
]);
}
Called when you click the "Comments" link in the main list:
RecipeService::getRecipeId()- Validates and retrieves the recipe'offcanvas_end'- Special key that tells JavaScript to open the offcanvas:'title'- Offcanvas title'body'- Complete HTML (title + search + table) generated bygetCommentOffcanvasHtml()'size'- 'lg' for wide offcanvas
- This is the first load: generates all the HTML
7.3 updateCommentTable() - Reload Table on Sort/Search/Pagination
#[RequestAction('update-comment-table')]
public function updateCommentTable() {
$recipe = RecipeService::getRecipeId();
$tableBuilder = RecipeService::getCommentTable($recipe);
$response = $tableBuilder->getResponse();
$response['list'] = [
"id" => "idTableRecipes",
"action" => "reload"
];
Response::json($response);
}
This method doesn't exist in the Posts module because Posts always reloads the entire page.
When called:
- When sorting a column in the comments table
- When searching in the search bar
- When changing page in pagination
- This happens because in
getCommentTable()there's->setRequestAction('update-comment-table')
What it does:
$tableBuilder->getResponse()- Generates updated HTML for ONLY the table$response['list']- FUNDAMENTAL:- Tells JavaScript to ALSO reload the main table
idTableRecipes - Why? To update the comments counter!
- If you add/modify/delete a comment, the number in the "Comments" column of the main list must update
- Tells JavaScript to ALSO reload the main table
Why important: Updates TWO tables with one action:
- The comments table (in offcanvas) with new sorted/filtered data
- The main table (in page) to recalculate the counter
7.4 commentEdit() - Open Modal with Comment Form
#[RequestAction('comment-edit')]
public function commentEdit() {
$recipe = RecipeService::getRecipeId();
$formBuilder = RecipeService::getCommentForm($recipe);
Response::json($formBuilder->getResponse());
}
- Called when you click "Edit" or "New Comment"
RecipeService::getCommentForm($recipe)- Generates the FormBuilder->getResponse()- Returns array with all form configurations- JavaScript reads this JSON and opens the modal with the form
8. Step 6: Create the View
Create milkadmin_local/Modules/Recipe/Views/list_page.php:
<?php
namespace Modules\Posts\Views;
use Builders\TitleBuilder;
!defined('MILK_DIR') && die(); // Avoid direct access
?>
<div class="card">
<div class="card-header">
<?php
$title = TitleBuilder::create($title)->addButton('Add New', '?page='.$page.'&action=edit', 'primary', '', 'post');
echo (isset($search_html)) ? $title->addRightContent($search_html) : $title->addSearch('idTableRecipes', 'Search...', 'Search');
?>
</div>
<div class="card-body">
<p class="text-body-secondary mb-3"><?php _pt('Recipe module with fetch-based comments management.') ?></p>
<?php _ph($html); ?>
</div>
</div>
- Very simple view, used only for the main list
->addButton('Add New', ..., 'primary', '', 'post')- Fifth parameter'post'addsdata-fetch="post"to button- All other interfaces (offcanvas, modal) are handled via JSON
9. Step 7: Understanding the Complete Fetch Flow
1. Click "Comments" (column in main list)
User: clicks link with data-fetch="post"
↓
JavaScript: intercepts click, makes fetch POST to ?page=recipe&action=comments&recipe_id=X
↓
Server: recipeComments() returns JSON:
{
"offcanvas_end": {
"title": "Comments",
"body": "<Complete HTML: title + search + table>",
"size": "lg"
}
}
↓
JavaScript: opens offcanvas and inserts HTML in body
↓
Result: offcanvas opened with comments list
2. Sort/Search/Pagination in comments table
User: clicks column header to sort
↓
JavaScript: TableBuilder in fetch mode makes POST to ?page=recipe&action=update-comment-table
Includes: recipe_id (from customData), sort, order, search, page
↓
Server: updateCommentTable() returns JSON:
{
"html": "<Updated table HTML only>",
"list": {
"id": "idTableRecipes",
"action": "reload"
}
}
↓
JavaScript:
1. Replaces table HTML in offcanvas
2. Reloads table idTableRecipes (main list) via fetch
↓
Result: comments table updated + counter in main list updated
3. Click "Edit" comment
User: clicks Edit button
↓
JavaScript: activeFetch() intercepts, makes fetch POST to ?page=recipe&action=comment-edit&id=X&recipe_id=Y
↓
Server: commentEdit() returns JSON with FormBuilder::getResponse():
{
"modal": {
"title": "Edit Comment",
"body": "<HTML form>",
...
}
}
↓
JavaScript: opens modal with the form
↓
Result: modal opened over the offcanvas
4. Submit form (Save comment)
User: clicks "Save"
↓
JavaScript: activeFetch() intercepts submit, makes fetch POST with form data
Includes: recipe_id, comment, id (from customData)
↓
Server: FormBuilder processes save, returns JSON:
{
"modal": {"action": "hide"},
"list": {
"id": "idTableRecipeComments",
"action": "reload"
}
}
(if validation errors, returns form with errors)
↓
JavaScript:
1. Closes modal
2. Reloads table idTableRecipeComments via fetch
This request calls update-comment-table which:
- Returns updated table HTML
- Includes "list": {"id": "idTableRecipes", "action": "reload"}
3. Also reloads table idTableRecipes
↓
Result: modal closed, comments table updated, counter updated
5. Delete comment
User: clicks "Delete"
↓
JavaScript: confirms, makes fetch POST with delete action
↓
Server: deleteComment() callback returns:
{"success": true, "message": "Item deleted successfully"}
Then automatically reloads table (like update-comment-table)
↓
JavaScript:
1. Shows toast with message
2. Reloads table idTableRecipeComments
3. Reloads table idTableRecipes (thanks to list: {...} in updateCommentTable)
↓
Result: comment deleted, tables updated
Key Flow Points
- First load complete:
recipeComments()generates all HTML (title + search + table) - Subsequent partial updates:
updateCommentTable()generates only table HTML - Double update: every comment modification updates both comments table and main list
- Automatic dataListId: when saving form, system automatically reloads specified table
- Cascade of updates: save form → reload comments table → reload main list
10. Step 8: Install and Test
Install Database Tables
Run the CLI command:
php milkadmin/cli.php recipes:update
This command:
- Creates table
#__recipeswith fields: id, name, ingredients, difficulty - Creates table
#__recipe_commentswith fields: id, recipe_id, comment
Testing Workflow
- Main list:
- Add some recipes via "Add New"
- Note that "Comments" column shows 0 with icon
- Open comments list:
- Click the number in "Comments" column
- Wide offcanvas opens with title, search, empty table
- Note: no page reload
- Add comment:
- Click "New Comment" in offcanvas
- Modal opens with form over offcanvas
- recipe_id is pre-filled and readonly
- Write a comment and save
- Modal closes, comments table updates, counter in main list changes to 1
- Edit comment:
- Click "Edit" in comment row
- Modal opens with comment loaded
- Modify and save
- Modal closes, table updates
- Search/Sort:
- Add more comments
- Try searching in search bar
- Only table updates, not entire offcanvas
- Click column header to sort
- Change page if many comments
- Delete comment:
- Click "Delete", confirm
- Success toast, table updates
- Counter in main list decreases
- Close offcanvas:
- Click close button or outside offcanvas
- Offcanvas closes, main list still there without reload
11. Key Concepts Summary
Enabling Fetch Mode
| Builder | Method | Effect |
|---|---|---|
| TableBuilder | ->activeFetch() |
Actions (edit, delete) use fetch instead of normal links |
| TableBuilder | ->setRequestAction('action-name') |
Sort/search/pagination call this action instead of default |
| FormBuilder | ->activeFetch() |
Submit uses fetch instead of normal POST |
Opening in Overlay
| Builder | Method | Effect |
|---|---|---|
| FormBuilder | ->asOffcanvas() |
Opens form in side offcanvas |
| FormBuilder | ->asModal() |
Opens form in centered modal |
Auto-Reloading Lists
| Builder | Method | Effect |
|---|---|---|
| FormBuilder | ->dataListId('tableId') |
After save, automatically reloads table with this ID |
Passing Context Data
| Builder | Method | Effect |
|---|---|---|
| TableBuilder | ->customData('key', $value) |
Passes extra data to all table actions |
| FormBuilder | ->customData('key', $value) |
Passes hidden extra data in form |
Manual List Reload in Action
In an action (e.g., updateCommentTable), you can force reload of other lists:
$response['list'] = [
"id" => "idTableRecipes",
"action" => "reload"
];
Response::json($response);
This tells JavaScript to also reload the idTableRecipes table.
data-fetch Attribute
In custom HTML links or TitleBuilder buttons:
// Custom link with data-fetch
'<a href="?page=recipe&action=comments&recipe_id='.$id.'" data-fetch="post">Comments</a>'
// TitleBuilder with fetch (fifth parameter)
TitleBuilder::create('Title')->addButton('Label', 'url', 'primary', '', 'post');
The 'post' parameter or data-fetch="post" attribute tells JavaScript to intercept the click and make a fetch request instead of following the link.
Response Types
| Module | Response Type | Usage |
|---|---|---|
| Posts | Response::render($view, $data) |
Loads complete HTML page |
| Recipe (home) | Response::render($view, $data) |
Only for main list |
| Recipe (actions) | Response::json($array) |
All other actions return JSON |
Service Class Pattern
The RecipeService class separates:
- Validation: getRecipeId()
- HTML generation: getCommentOffcanvasHtml()
- Builder configuration: getCommentTable(), getCommentForm()
- Callbacks: deleteComment()
This keeps Module/Controller methods lean and reusable.
FormBuilder::getForm() vs getResponse()
| Context | Method | Returns | Used with |
|---|---|---|---|
| Page reload | ->getForm() |
HTML string | Response::render() |
| Fetch mode | ->getResponse() |
Array | Response::json() |
First Load vs Subsequent Updates
| Type | Method | Generates | When |
|---|---|---|---|
| First load | getCommentOffcanvasHtml() |
Title + Search + Table | Click on "Comments" |
| Updates | updateCommentTable() |
Only table HTML | Sort/search/pagination |
This optimizes performance: first load is complete, but subsequent updates transfer only necessary data.
12. See Also
- Managing Related Content in Separate Pages - Page-based approach
- Fetch-Based Modules - Fetch for single modules
- FormBuilder Documentation
- TableBuilder Documentation