Milk Admin
Quick Form Creation with FormBuilder

This is a manual, artisanal system for creating autocomplete select fields. If you need to create forms quickly from your Models, we recommend using the FormBuilder which can generate complete forms in minutes:

→ Getting Started - Forms with FormBuilder

MilkSelect - Dynamic Autocomplete Select

MilkSelect is a powerful autocomplete component that supports both static options and dynamic API-based loading. It's perfect for handling relationships and large datasets.


Basic Usage - Static Options

For small, static datasets, you can pass options directly:

Single Selection

Form::milkSelect('country', 'Country',
    ['IT' => 'Italy', 'FR' => 'France', 'DE' => 'Germany'],
    'IT'  // selected value
);

Multiple Selection

Form::milkSelect('tags', 'Tags',
    ['php' => 'PHP', 'js' => 'JavaScript', 'py' => 'Python'],
    ['php', 'js'],  // selected values (array)
    ['type' => 'multiple']
);

Dynamic API Loading - The Power Feature

For large datasets or when you need search functionality, use API loading. This is the recommended approach for relationships.

Step-by-Step Implementation

Step 1: Configure the Model Field

In your model's configure() method, use apiUrl() to specify the API endpoint and display field:

// Example: CarsModel.php
class CarsModel extends AbstractModel
{
    protected function configure($rule): void
    {
        $rule->table('#__cars')
            ->id()
            ->string('model', 100)->required()
            ->string('color', 50)

            // Single selection with belongsTo
            ->int('manufacturer_id')
                ->belongsTo('manufacturer', ManufacturersModel::class, 'id')
                ->formType('milkSelect')
                ->apiUrl('?page=cars&action=search-manufacturers', 'name')
                ->required()
                ->error('Please select a manufacturer');
    }
}
📌 Important:
  • apiUrl() takes 2 parameters: the API endpoint and the field to display
  • belongsTo() is required for automatic relationship handling
  • The field type should be int for single selection with belongsTo

Step 2: Create the Controller Action

Add an action in your Controller to handle the search requests:

// Example: CarsController.php
class CarsController extends AbstractController
{
    #[RequestAction('search-manufacturers')]
    public function searchManufacturers() {
        $search = $_REQUEST['q'] ?? '';
        $options = $this->model->searchRelated($search, 'manufacturer_id');
        Response::json([
            'success' => 'ok',
            'options' => $options
        ]);
    }
}
⚠️ Note: The action name must match the one in apiUrl(). The search term is passed as q parameter.

Step 3: Add Search Method to Model

Use the generic searchRelated() method that reads configuration from the relationship:

// Add to your Model (e.g., CarsModel.php)
/**
 * Generic search for autocomplete based on relationship configuration
 */
public function searchRelated(string $search = '', string $field_name = 'manufacturer_id'): array
{
    $rules = $this->getRules();

    // Get the relationship configuration
    if (!isset($rules[$field_name]['relationship']) ||
        $rules[$field_name]['relationship']['type'] !== 'belongsTo') {
        return [];
    }

    $relationship = $rules[$field_name]['relationship'];
    $related_model_class = $relationship['related_model'];
    $display_field = $rules[$field_name]['api_display_field'] ?? 'name';
    $related_key = $relationship['related_key'] ?? 'id';

    // Instantiate related model
    $relatedModel = new $related_model_class();
    $query = $relatedModel->query();

    // Filter by search term on display field
    if (!empty($search)) {
        $query->where("$display_field LIKE ?", '%' . $search . '%');
    }

    $results = $query->limit(0, 20)->getResults();
    $options = [];

    foreach ($results as $result) {
        $options[$result->$related_key] = $result->$display_field;
    }

    return $options;
}
✅ Benefits: This method is generic and reusable! It automatically reads:
  • The related model class from belongsTo()
  • The display field from apiUrl()
  • The key field from the relationship

Multiple Selection WITHOUT belongsTo

For many-to-many relationships or when you don't need belongsTo, use a text field with multiple mode:

// In your Model
->text('category_ids')
    ->label('Categories')
    ->formType('milkSelect')
    ->apiUrl('?page=products&action=search-categories', 'name')
    ->formParams(['type' => 'multiple'])
    ->excludeFromDatabase();  // if it's not a real DB column
📌 Key Differences:
  • Use text type instead of int
  • No belongsTo() relationship
  • Add ['type' => 'multiple'] in formParams
  • Data is saved as JSON array: ["1","2","3"]

Complete Working Example

Here's a minimal but complete example you can use as a starting point:

1. Create the Models

ManufacturersModel.php

namespace Modules\Examples;
use App\Abstracts\AbstractModel;

class ManufacturersModel extends AbstractModel
{
    protected function configure($rule): void
    {
        $rule->table('#__manufacturers')
            ->id()
            ->string('name', 100)->required()
            ->string('country', 50);
    }
}

CarsModel.php

namespace Modules\Examples;
use App\Abstracts\AbstractModel;

class CarsModel extends AbstractModel
{
    protected function configure($rule): void
    {
        $rule->table('#__cars')
            ->id()
            ->string('model', 100)->required()
            ->int('manufacturer_id')
                ->belongsTo('manufacturer', ManufacturersModel::class, 'id')
                ->formType('milkSelect')
                ->apiUrl('?page=examples-cars&action=search-manufacturers', 'name')
                ->required();
    }

    public function searchRelated(string $search = '', string $field_name = 'manufacturer_id'): array
    {
        $rules = $this->getRules();
        if (!isset($rules[$field_name]['relationship']) ||
            $rules[$field_name]['relationship']['type'] !== 'belongsTo') {
            return [];
        }

        $relationship = $rules[$field_name]['relationship'];
        $related_model_class = $relationship['related_model'];
        $display_field = $rules[$field_name]['api_display_field'] ?? 'name';
        $related_key = $relationship['related_key'] ?? 'id';

        $relatedModel = new $related_model_class();
        $query = $relatedModel->query();

        if (!empty($search)) {
            $query->where("$display_field LIKE ?", '%' . $search . '%');
        }

        $results = $query->limit(0, 20)->getResults();
        $options = [];

        foreach ($results as $result) {
            $options[$result->$related_key] = $result->$display_field;
        }

        return $options;
    }
}

2. Create the Controller

namespace Modules\Examples;
use App\{Response};
use App\Abstracts\AbstractController;
use App\Attributes\RequestAction;
use Builders\{TableBuilder, FormBuilder};

class CarsController extends AbstractController
{
    #[RequestAction('home')]
    public function carsList() {
        $response = TableBuilder::create($this->model, 'idTableCars')
            ->column('manufacturer.name', 'Manufacturer')
            ->setDefaultActions()
            ->getResponse();

        $response['page'] = $this->page;
        $response['title'] = 'Cars Management';
        Response::render(__DIR__ . '/views/cars_list.php', $response);
    }

    #[RequestAction('car-edit')]
    public function carEdit() {
        $response = ['page' => $this->page, 'title' => 'Edit Car'];
        $response['form'] = FormBuilder::create($this->model)->getForm();
        Response::render(__DIR__ . '/views/car_edit.php', $response);
    }

    #[RequestAction('search-manufacturers')]
    public function searchManufacturers() {
        $search = $_REQUEST['q'] ?? '';
        $options = $this->model->searchRelated($search, 'manufacturer_id');
        Response::json([
            'success' => 'ok',
            'options' => $options
        ]);
    }
}

How It Works

  1. Initial Load: When editing an existing record, the system uses lazy loading to display the current value (e.g., "Toyota" instead of "1")
  2. User Types: After 300ms, a fetch request is sent to the API with the search term
  3. API Response: The server returns filtered options: {"success":"ok", "options":{"1":"Toyota","2":"Honda"}}
  4. Display: Options are shown in the dropdown
  5. Selection: The ID is saved in the database, but the name is displayed to the user

Configuration Options

Field Configuration (in Model)

Method Parameters Description
apiUrl() $url, $display_field Set API endpoint and field to display
belongsTo() $alias, $model, $key Define relationship for automatic handling
formType() 'milkSelect' Set field type to MilkSelect
formParams() ['type' => 'multiple'] Enable multiple selection mode
required() - Make field required
error() $message Custom validation error message

Form Options

Option Values Description
type 'single', 'multiple' Selection mode
required true, false Field validation
class string Additional CSS classes
placeholder string Placeholder text (not with floating)
floating true, false Enable floating label (default: true)
invalid-feedback string Custom error message

API Response Format

Your API endpoint must return JSON in this format:

{
    "success": "ok",
    "options": {
        "1": "Toyota",
        "2": "Honda",
        "3": "Ford"
    }
}

Or for simple arrays without keys:

{
    "success": "ok",
    "options": ["Red", "Blue", "Green"]
}
⚠️ Error Handling: If the response has "success" !== "ok" or fetch fails, a simple alert will be shown to the user.

Best Practices

✅ Do:
  • Use apiUrl() for large datasets (> 50 items)
  • Use belongsTo() with single selection for automatic relationship handling
  • Limit API results to 20-50 items for better performance
  • Use the generic searchRelated() method for consistency
  • Add indexes on search fields in your database
❌ Don't:
  • Don't use belongsTo() with array/text fields (only with int)
  • Don't return more than 100 results from the API
  • Don't forget to sanitize the search parameter in your API
  • Don't use static options for large datasets

Troubleshooting

Problem: Shows ID instead of name

Solution: Make sure you're using belongsTo() and the relationship is properly configured. The lazy loading will handle displaying the correct value.

Problem: API not called when typing

Solution: Check that:

  • The apiUrl() endpoint is correct
  • The router action exists and is accessible
  • The browser console shows no JavaScript errors

Problem: No options shown

Solution: Verify the API response format matches the expected structure. Check the network tab in browser DevTools.


Advanced: Custom Search Logic

You can override searchRelated() for custom search logic:

public function searchManufacturers(string $search = ''): array
{
    $model = new ManufacturersModel();
    $query = $model->query();

    if (!empty($search)) {
        // Custom logic: search in name AND country
        $query->where("(name LIKE ? OR country LIKE ?)",
            '%' . $search . '%',
            '%' . $search . '%'
        );
    }

    $results = $query->orderBy('name ASC')->limit(0, 20)->getResults();
    $options = [];

    foreach ($results as $item) {
        // Custom format: show both name and country
        $options[$item->id] = $item->name . ' (' . $item->country . ')';
    }

    return $options;
}
Loading...